diff --git a/js/components/assets/badges/live.png b/js/components/assets/badges/live.png new file mode 100644 index 0000000000000000000000000000000000000000..32c5d0ada48fd269483e89b77ef1a3e6453823f5 GIT binary patch literal 267 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}mpok@Ln7SYPQS?8puoeL z%6+|vMb^i?KzI9vjG(57ocUo-nC(5E$hwQZl6v;?cHYxt_xpTLzj+-s@~&({eEVu)A?@)>_4&bmy7>8Gq%i)wwgbCsp1}Y^%auZWm_% z?ao}wDz7qh@aF!oQ)f}Q>Cm*d@s{`mt|wJ}_y4WWpA)tDjzCValu=!3bM|?2r_}27 VRTs}5U|?Wi@O1TaS?83{1OR`KYjpqs literal 0 HcmV?d00001 diff --git a/js/components/assets/badges/mod.png b/js/components/assets/badges/mod.png new file mode 100644 index 0000000000000000000000000000000000000000..8d90e64abf3338286f155a6188dba9d6bff98211 GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}uRUEHLn7SYPCv-oY{26> zTX$EOyTjdu>})Rk&pa1+#`*k0ch^I!yU}{=>bVee-OicfZtUJW#utWfoJ$k?SpY zvKMGFh)sX9KbtB2$X$k_!j;wH4Ba~<4`{7pia7dV>np`|Yz=$A3fZ$dluldRcDPje zs_T=NG7Eed#GYp{oZ9ors?n)iI^kMhNlxg13nN0$U<20Z@B)c1H+dUx8KoeT^N44$rjF6*2UngIS|eck{7 literal 0 HcmV?d00001 diff --git a/js/components/assets/badges/vip.png b/js/components/assets/badges/vip.png new file mode 100644 index 0000000000000000000000000000000000000000..1a3535e3e8382b0cc844059732b2bf1dd0ff69f0 GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4mJh`hT^KKFANL}_dH!3Ln7SYPQS=|$biS? zweE|E4U9Z&(gz|oxUN##S*j!aT*UO@;lEqfeS5vNENfQJ>nbaC?R!$yHE~OdQyLd@ zy5`-@&f*}~r{Pnu5TT)%Md=EHfc?@nIU;AkjF*u%V0yg>T;pE+jRYRam&&HCDA kFg>`le2Vn7s(lQ$vyP`%%$>A>fq{X+)78&qol`;+0Fd%{s{jB1 literal 0 HcmV?d00001 diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index eb58b947..0f9d7ab2 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -4,7 +4,7 @@ import { Mention, } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; import { memo, useCallback } from "react"; -import { Linking, View } from "react-native"; +import { Image, Linking, View } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; import { RichtextSegment, segmentize } from "../../lib/facet"; import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; @@ -23,6 +23,7 @@ interface Facet { }>; } +import { zero } from "../.."; import { useLivestreamStore } from "../../livestream-store"; import { Text } from "../ui/text"; @@ -164,11 +165,33 @@ export const RenderChatMessage = memo( style={{ fontVariant: ["tabular-nums"], color: colors.gray[400], + width: 44, }} > {formatTime(item.record.createdAt)} )} + {item.badges?.length ? ( + + {item.badges.map((badge, index) => ( + + {badge.badgeType === "place.stream.badge.defs#mod" ? ( + + ) : badge.badgeType === "place.stream.badge.defs#streamer" ? ( + + ) : ( + + )} + + ))} + + ) : null} + +### `badgeView` + +**Type:** `object` + +View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer` | +| `issuer` | `string` | ✅ | DID of the badge issuer. | Format: `did` | +| `recipient` | `string` | ✅ | DID of the badge recipient. | Format: `did` | +| `signature` | `string` | ❌ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | + +--- + + + +### `mod` + +**Type:** `token` + +This user is a moderator. Displayed with a sword icon. + +--- + + + +### `streamer` + +**Type:** `token` + +This user is the streamer. Displayed with a star icon. + +--- + + + +### `vip` + +**Type:** `token` + +This user is a very important person. + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.defs", + "defs": { + "badgeView": { + "type": "object", + "required": ["badgeType", "issuer", "recipient"], + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", + "properties": { + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#mod", + "place.stream.badge.defs#streamer" + ] + }, + "issuer": { + "type": "string", + "format": "did", + "description": "DID of the badge issuer." + }, + "recipient": { + "type": "string", + "format": "did", + "description": "DID of the badge recipient." + }, + "signature": { + "type": "string", + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." + } + } + }, + "mod": { + "type": "token", + "description": "This user is a moderator. Displayed with a sword icon." + }, + "streamer": { + "type": "token", + "description": "This user is the streamer. Displayed with a star icon." + }, + "vip": { + "type": "token", + "description": "This user is a very important person." + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md new file mode 100644 index 00000000..718ceb62 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md @@ -0,0 +1,90 @@ +--- +title: place.stream.badge.display +description: Reference for the place.stream.badge.display lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `record` + +Record issuing a badge to a user. + +**Record Properties:** + +| Name | Type | Req'd | Description | Constraints | +| -------- | --------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | +| `badges` | Array of [`#badgeSelection`](#badgeselection) | ✅ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. | Max Items: 3 | + +--- + + + +### `badgeSelection` + +**Type:** `object` + +A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ----------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#vip` | +| `issuance` | `string` | ❌ | URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. | Format: `at-uri` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.display", + "defs": { + "main": { + "type": "record", + "description": "Record issuing a badge to a user.", + "record": { + "type": "object", + "required": ["badges"], + "properties": { + "badges": { + "type": "array", + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", + "maxLength": 3, + "items": { + "type": "ref", + "ref": "#badgeSelection" + } + } + } + } + }, + "badgeSelection": { + "type": "object", + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", + "required": ["badgeType"], + "properties": { + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#mod", + "place.stream.badge.defs#vip" + ] + }, + "issuance": { + "type": "string", + "format": "at-uri", + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md new file mode 100644 index 00000000..df4ee9a0 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md @@ -0,0 +1,63 @@ +--- +title: place.stream.badge.issuance +description: Reference for the place.stream.badge.issuance lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `record` + +Record issuing a badge to a user. + +**Record Key:** `tid` + +**Record Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------- | +| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#vip` | +| `recipient` | `string` | ✅ | DID of the badge recipient. | Format: `did` | +| `signature` | `string` | ✅ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.issuance", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "Record issuing a badge to a user.", + "record": { + "type": "object", + "required": ["badgeType", "recipient", "signature"], + "properties": { + "badgeType": { + "type": "string", + "knownValues": ["place.stream.badge.defs#vip"] + }, + "recipient": { + "type": "string", + "format": "did", + "description": "DID of the badge recipient." + }, + "signature": { + "type": "string", + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md index f7927cb3..382fe718 100644 --- a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md +++ b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md @@ -15,16 +15,17 @@ description: Reference for the place.stream.chat.defs lexicon **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` | ✅ | | | -| `indexedAt` | `string` | ✅ | | Format: `datetime` | -| `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | -| `replyTo` | Union of:
  [`#messageView`](#messageview) | ❌ | | | -| `deleted` | `boolean` | ❌ | If true, this message has been deleted or labeled and should be cleared from the cache | | +| 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` | ✅ | | | +| `indexedAt` | `string` | ✅ | | Format: `datetime` | +| `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | +| `replyTo` | Union of:
  [`#messageView`](#messageview) | ❌ | | | +| `deleted` | `boolean` | ❌ | If true, this message has been deleted or labeled and should be cleared from the cache | | +| `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | ❌ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. | Max Items: 3 | --- @@ -69,6 +70,15 @@ description: Reference for the place.stream.chat.defs lexicon "deleted": { "type": "boolean", "description": "If true, this message has been deleted or labeled and should be cleared from the cache" + }, + "badges": { + "type": "array", + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", + "maxLength": 3, + "items": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView" + } } } } diff --git a/lexicons/place/stream/badge/defs.json b/lexicons/place/stream/badge/defs.json new file mode 100644 index 00000000..30845db2 --- /dev/null +++ b/lexicons/place/stream/badge/defs.json @@ -0,0 +1,46 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.defs", + "defs": { + "badgeView": { + "type": "object", + "required": ["badgeType", "issuer", "recipient"], + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", + "properties": { + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#mod", + "place.stream.badge.defs#streamer" + ] + }, + "issuer": { + "type": "string", + "format": "did", + "description": "DID of the badge issuer." + }, + "recipient": { + "type": "string", + "format": "did", + "description": "DID of the badge recipient." + }, + "signature": { + "type": "string", + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." + } + } + }, + "mod": { + "type": "token", + "description": "This user is a moderator. Displayed with a sword icon." + }, + "streamer": { + "type": "token", + "description": "This user is the streamer. Displayed with a star icon." + }, + "vip": { + "type": "token", + "description": "This user is a very important person." + } + } +} diff --git a/lexicons/place/stream/badge/display.json b/lexicons/place/stream/badge/display.json new file mode 100644 index 00000000..2f34939b --- /dev/null +++ b/lexicons/place/stream/badge/display.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.display", + "defs": { + "main": { + "type": "record", + "description": "Record issuing a badge to a user.", + "record": { + "type": "object", + "required": ["badges"], + "properties": { + "badges": { + "type": "array", + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", + "maxLength": 3, + "items": { + "type": "ref", + "ref": "#badgeSelection" + } + } + } + } + }, + "badgeSelection": { + "type": "object", + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", + "required": ["badgeType"], + "properties": { + "badgeType": { + "type": "string", + "knownValues": [ + "place.stream.badge.defs#mod", + "place.stream.badge.defs#vip" + ] + }, + "issuance": { + "type": "string", + "format": "at-uri", + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." + } + } + } + } +} diff --git a/lexicons/place/stream/badge/issuance.json b/lexicons/place/stream/badge/issuance.json new file mode 100644 index 00000000..6566d135 --- /dev/null +++ b/lexicons/place/stream/badge/issuance.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.issuance", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "Record issuing a badge to a user.", + "record": { + "type": "object", + "required": ["badgeType", "recipient", "signature"], + "properties": { + "badgeType": { + "type": "string", + "knownValues": ["place.stream.badge.defs#vip"] + }, + "recipient": { + "type": "string", + "format": "did", + "description": "DID of the badge recipient." + }, + "signature": { + "type": "string", + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." + } + } + } + } + } +} diff --git a/lexicons/place/stream/chat/defs.json b/lexicons/place/stream/chat/defs.json index af53a8ce..da097e62 100644 --- a/lexicons/place/stream/chat/defs.json +++ b/lexicons/place/stream/chat/defs.json @@ -25,6 +25,15 @@ "deleted": { "type": "boolean", "description": "If true, this message has been deleted or labeled and should be cleared from the cache" + }, + "badges": { + "type": "array", + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", + "maxLength": 3, + "items": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView" + } } } } diff --git a/pkg/api/websocket.go b/pkg/api/websocket.go index 6373111f..50a59a96 100644 --- a/pkg/api/websocket.go +++ b/pkg/api/websocket.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "net" "net/http" "time" @@ -12,6 +13,7 @@ import ( "github.com/gorilla/websocket" "github.com/julienschmidt/httprouter" + "stream.place/streamplace/pkg/atproto" apierrors "stream.place/streamplace/pkg/errors" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/renditions" @@ -237,7 +239,14 @@ func (a *StreamplaceAPI) HandleWebsocket(ctx context.Context) httprouter.Handle log.Error(ctx, "could not get chat messages", "error", err) return } + + // Add mod badges to messages + issuerDID := fmt.Sprintf("did:web:%s", a.CLI.BroadcasterHost) for _, message := range messages { + err := atproto.AddModBadgeIfApplicable(ctx, message, repoDID, issuerDID, a.Model) + if err != nil { + log.Error(ctx, "failed to add mod badge to message", "error", err) + } initialBurst <- message } }() diff --git a/pkg/atproto/badges.go b/pkg/atproto/badges.go new file mode 100644 index 00000000..1f052182 --- /dev/null +++ b/pkg/atproto/badges.go @@ -0,0 +1,61 @@ +package atproto + +import ( + "context" + "fmt" + + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" +) + +// AddModBadgeIfApplicable checks if a message author has mod permissions for the streamer +// and adds a mod or streamer badge as the first badge (server-controlled). +// - If the author is the streamer, adds a "streamer" badge +// - If the author has moderation permissions, adds a "mod" badge +func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_MessageView, streamerDID string, issuerDID string, m model.Model) error { + if message == nil { + return fmt.Errorf("message is nil") + } + + authorDID := message.Author.Did + + var badge *streamplace.BadgeDefs_BadgeView + + // Check if author is the streamer + if authorDID == streamerDID { + badge = &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeStreamer, + Issuer: issuerDID, + Recipient: authorDID, + } + } else { + // Check if author has any moderation permissions for the streamer + delegations, err := m.GetModerationDelegations(ctx, streamerDID, authorDID) + if err != nil { + log.Error(ctx, "failed to get moderation delegations", "err", err, "authorDID", authorDID, "streamerDID", streamerDID) + return err + } + + // If the author has any delegations (meaning they're a moderator), add a mod badge + if len(delegations) > 0 { + badge = &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeMod, + Issuer: issuerDID, + Recipient: authorDID, + } + } + } + + // Prepend the badge if one was created (server-controlled badge is first) + if badge != nil { + if message.Badges == nil { + message.Badges = []*streamplace.BadgeDefs_BadgeView{badge} + } else { + message.Badges = append([]*streamplace.BadgeDefs_BadgeView{badge}, message.Badges...) + } + } + + return nil +} diff --git a/pkg/atproto/badges_test.go b/pkg/atproto/badges_test.go new file mode 100644 index 00000000..b06b259e --- /dev/null +++ b/pkg/atproto/badges_test.go @@ -0,0 +1,100 @@ +package atproto + +import ( + "context" + "testing" + "time" + + bsky "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/util" + "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" +) + +func TestAddModBadge(t *testing.T) { + ctx := context.Background() + + mod, err := model.MakeDB(":memory:") + require.NoError(t, err) + + streamerDID := "did:plc:streamer" + moderatorDID := "did:plc:moderator" + issuerDID := "did:web:example.com" + + // Create a chat message + message := &streamplace.ChatDefs_MessageView{ + LexiconTypeID: "place.stream.chat.defs#messageView", + Uri: "at://test/place.stream.chat.message/123", + Cid: "test-cid", + Author: &bsky.ActorDefs_ProfileViewBasic{ + Did: moderatorDID, + Handle: "moderator.test", + }, + IndexedAt: "2024-01-01T00:00:00Z", + } + + t.Run("no badge when user is not a moderator", func(t *testing.T) { + msg := *message // copy + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Nil(t, msg.Badges, "should not have badges when user is not a moderator") + }) + + t.Run("adds streamer badge when user is the streamer", func(t *testing.T) { + msg := *message // copy + msg.Author = &bsky.ActorDefs_ProfileViewBasic{ + Did: streamerDID, + Handle: "streamer.test", + } + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, msg.Badges, 1, "should have 1 badge when user is the streamer") + require.Equal(t, "place.stream.badge.defs#streamer", msg.Badges[0].BadgeType) + require.Equal(t, issuerDID, msg.Badges[0].Issuer) + require.Equal(t, streamerDID, msg.Badges[0].Recipient) + }) + + t.Run("adds mod badge when user has moderation permissions", func(t *testing.T) { + // Grant moderation permissions to the moderator + perm := &streamplace.ModerationPermission{ + LexiconTypeID: "place.stream.moderation.permission", + Moderator: moderatorDID, + Permissions: []string{"ban", "hide"}, + CreatedAt: time.Now().Format(util.ISO8601), + } + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") + require.NoError(t, err) + + // Sync the permission to the model + err = mod.CreateModerationDelegation(ctx, perm, aturi) + require.NoError(t, err) + + msg := *message // copy + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, msg.Badges, 1, "should have 1 badge when user is a moderator") + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType) + require.Equal(t, issuerDID, msg.Badges[0].Issuer) + require.Equal(t, moderatorDID, msg.Badges[0].Recipient) + }) + + t.Run("prepends mod badge to existing badges", func(t *testing.T) { + // Create message with existing user-settable badge + msg := *message // copy + msg.Badges = []*streamplace.BadgeDefs_BadgeView{ + { + BadgeType: "place.stream.badges.badge#vip", + Issuer: "did:web:other.com", + Recipient: moderatorDID, + }, + } + + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, msg.Badges, 2, "should have 2 badges") + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType, "mod badge should be first") + require.Equal(t, "place.stream.badges.badge#vip", msg.Badges[1].BadgeType, "vip badge should be second") + }) +} diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index d7ca198a..f99002ff 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -150,6 +150,14 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) return nil } + + // Add mod badge if the author is a moderator + issuerDID := fmt.Sprintf("did:web:%s", atsync.CLI.BroadcasterHost) + err = AddModBadgeIfApplicable(ctx, scm, rec.Streamer, issuerDID, atsync.Model) + if err != nil { + log.Error(ctx, "failed to add mod badge", "err", err) + } + go atsync.Bus.Publish(rec.Streamer, scm) if !isUpdate && !isFirstSync { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 92474d55..439150e8 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -15,6 +15,12 @@ var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" // var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all +// Streamplace badge types +const ( + BadgeTypeMod = "place.stream.badge.defs#mod" + BadgeTypeStreamer = "place.stream.badge.defs#streamer" +) + const DID_KEY_PREFIX = "did:key" //nolint:all const ADDRESS_KEY_PREFIX = "0x" //nolint:all diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go index 6d8fbbf8..0ab88e75 100644 --- a/pkg/gen/gen.go +++ b/pkg/gen/gen.go @@ -36,6 +36,9 @@ func main() { streamplace.ModerationPermission{}, streamplace.LiveTeleport{}, streamplace.LiveRecommendations{}, + streamplace.BadgeIssuance{}, + streamplace.BadgeDisplay{}, + streamplace.BadgeDisplay_BadgeSelection{}, ); err != nil { panic(err) } diff --git a/pkg/streamplace/badgedefs.go b/pkg/streamplace/badgedefs.go new file mode 100644 index 00000000..b1f2ef27 --- /dev/null +++ b/pkg/streamplace/badgedefs.go @@ -0,0 +1,18 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.defs + +package streamplace + +// BadgeDefs_BadgeView is a "badgeView" in the place.stream.badge.defs schema. +// +// View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. +type BadgeDefs_BadgeView struct { + BadgeType string `json:"badgeType" cborgen:"badgeType"` + // issuer: DID of the badge issuer. + Issuer string `json:"issuer" cborgen:"issuer"` + // recipient: DID of the badge recipient. + Recipient string `json:"recipient" cborgen:"recipient"` + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). + Signature *string `json:"signature,omitempty" cborgen:"signature,omitempty"` +} diff --git a/pkg/streamplace/badgedisplay.go b/pkg/streamplace/badgedisplay.go new file mode 100644 index 00000000..a0c43b6b --- /dev/null +++ b/pkg/streamplace/badgedisplay.go @@ -0,0 +1,28 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.display + +package streamplace + +import ( + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +func init() { + lexutil.RegisterType("place.stream.badge.display", &BadgeDisplay{}) +} + +type BadgeDisplay struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.display"` + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. + Badges []*BadgeDisplay_BadgeSelection `json:"badges" cborgen:"badges"` +} + +// BadgeDisplay_BadgeSelection is a "badgeSelection" in the place.stream.badge.display schema. +// +// A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. +type BadgeDisplay_BadgeSelection struct { + BadgeType string `json:"badgeType" cborgen:"badgeType"` + // issuance: URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. + Issuance *string `json:"issuance,omitempty" cborgen:"issuance,omitempty"` +} diff --git a/pkg/streamplace/badgeissuance.go b/pkg/streamplace/badgeissuance.go new file mode 100644 index 00000000..314ad25f --- /dev/null +++ b/pkg/streamplace/badgeissuance.go @@ -0,0 +1,22 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.issuance + +package streamplace + +import ( + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +func init() { + lexutil.RegisterType("place.stream.badge.issuance", &BadgeIssuance{}) +} + +type BadgeIssuance struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.issuance"` + BadgeType string `json:"badgeType" cborgen:"badgeType"` + // recipient: DID of the badge recipient. + Recipient string `json:"recipient" cborgen:"recipient"` + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). + Signature string `json:"signature" cborgen:"signature"` +} diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index 3bc2fee9..c2648aec 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -5966,3 +5966,530 @@ func (t *LiveRecommendations) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *BadgeIssuance) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{164}); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.issuance"))); err != nil { + return err + } + if _, err := cw.WriteString(string("place.stream.badge.issuance")); err != nil { + return err + } + + // t.BadgeType (string) (string) + if len("badgeType") > 1000000 { + return xerrors.Errorf("Value in field \"badgeType\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { + return err + } + if _, err := cw.WriteString(string("badgeType")); err != nil { + return err + } + + if len(t.BadgeType) > 1000000 { + return xerrors.Errorf("Value in field t.BadgeType was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { + return err + } + + // t.Recipient (string) (string) + if len("recipient") > 1000000 { + return xerrors.Errorf("Value in field \"recipient\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("recipient"))); err != nil { + return err + } + if _, err := cw.WriteString(string("recipient")); err != nil { + return err + } + + if len(t.Recipient) > 1000000 { + return xerrors.Errorf("Value in field t.Recipient was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Recipient))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Recipient)); err != nil { + return err + } + + // t.Signature (string) (string) + if len("signature") > 1000000 { + return xerrors.Errorf("Value in field \"signature\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("signature"))); err != nil { + return err + } + if _, err := cw.WriteString(string("signature")); err != nil { + return err + } + + if len(t.Signature) > 1000000 { + return xerrors.Errorf("Value in field t.Signature was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Signature))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Signature)); err != nil { + return err + } + return nil +} + +func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { + *t = BadgeIssuance{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("BadgeIssuance: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 9) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.BadgeType (string) (string) + case "badgeType": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.BadgeType = string(sval) + } + // t.Recipient (string) (string) + case "recipient": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Recipient = string(sval) + } + // t.Signature (string) (string) + case "signature": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Signature = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *BadgeDisplay) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{162}); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.display"))); err != nil { + return err + } + if _, err := cw.WriteString(string("place.stream.badge.display")); err != nil { + return err + } + + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) + if len("badges") > 1000000 { + return xerrors.Errorf("Value in field \"badges\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badges"))); err != nil { + return err + } + if _, err := cw.WriteString(string("badges")); err != nil { + return err + } + + if len(t.Badges) > 8192 { + return xerrors.Errorf("Slice value in field t.Badges was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Badges))); err != nil { + return err + } + for _, v := range t.Badges { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + return nil +} + +func (t *BadgeDisplay) UnmarshalCBOR(r io.Reader) (err error) { + *t = BadgeDisplay{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("BadgeDisplay: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 6) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) + case "badges": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Badges: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Badges = make([]*BadgeDisplay_BadgeSelection, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Badges[i] = new(BadgeDisplay_BadgeSelection) + if err := t.Badges[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Badges[i] pointer: %w", err) + } + } + + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *BadgeDisplay_BadgeSelection) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 2 + + if t.Issuance == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Issuance (string) (string) + if t.Issuance != nil { + + if len("issuance") > 1000000 { + return xerrors.Errorf("Value in field \"issuance\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issuance"))); err != nil { + return err + } + if _, err := cw.WriteString(string("issuance")); err != nil { + return err + } + + if t.Issuance == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Issuance) > 1000000 { + return xerrors.Errorf("Value in field t.Issuance was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Issuance))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Issuance)); err != nil { + return err + } + } + } + + // t.BadgeType (string) (string) + if len("badgeType") > 1000000 { + return xerrors.Errorf("Value in field \"badgeType\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { + return err + } + if _, err := cw.WriteString(string("badgeType")); err != nil { + return err + } + + if len(t.BadgeType) > 1000000 { + return xerrors.Errorf("Value in field t.BadgeType was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { + return err + } + return nil +} + +func (t *BadgeDisplay_BadgeSelection) UnmarshalCBOR(r io.Reader) (err error) { + *t = BadgeDisplay_BadgeSelection{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("BadgeDisplay_BadgeSelection: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 9) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Issuance (string) (string) + case "issuance": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Issuance = (*string)(&sval) + } + } + // t.BadgeType (string) (string) + case "badgeType": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.BadgeType = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/streamplace/chatdefs.go b/pkg/streamplace/chatdefs.go index 499910d9..6670cff7 100644 --- a/pkg/streamplace/chatdefs.go +++ b/pkg/streamplace/chatdefs.go @@ -16,8 +16,10 @@ import ( type ChatDefs_MessageView struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` - ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` - Cid string `json:"cid" cborgen:"cid"` + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. + Badges []*BadgeDefs_BadgeView `json:"badges,omitempty" cborgen:"badges,omitempty"` + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` + Cid string `json:"cid" cborgen:"cid"` // deleted: If true, this message has been deleted or labeled and should be cleared from the cache Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` -- 2.51.2 From 856e944903f630b3195f6fb61e41cde9a2feba0f Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:34:53 -0600 Subject: [PATCH 02/49] blogging --- js/docs/astro.config.mjs | 4 ++ .../src/content/docs/features-dev/badges.md | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 js/docs/src/content/docs/features-dev/badges.md diff --git a/js/docs/astro.config.mjs b/js/docs/astro.config.mjs index ef3bbe72..bd138404 100644 --- a/js/docs/astro.config.mjs +++ b/js/docs/astro.config.mjs @@ -80,6 +80,10 @@ export default defineConfig({ label: "Installing Streamplace", autogenerate: { directory: "guides/installing" }, }, + { + label: "Features (Dev)", + autogenerate: { directory: "guides/features-dev" }, + }, { label: "Video Metadata", autogenerate: { directory: "video-metadata" }, diff --git a/js/docs/src/content/docs/features-dev/badges.md b/js/docs/src/content/docs/features-dev/badges.md new file mode 100644 index 00000000..7a4b6b6e --- /dev/null +++ b/js/docs/src/content/docs/features-dev/badges.md @@ -0,0 +1,39 @@ +--- +title: badges system +description: user badges for chat messages +--- + +## Overview + +Badges appear next to usernames in chat messages. they're small icons that indicate status (streamer, mod, vip, etc.). There will be max 3 badges shown at once. One of the badges is server-based (e.g. streamer, mod, node staff badge), but the other two can be selected from a pool of cosmetic badges (such as subscription badges, event badges et al.). These cosmetic badges are cryptographically signed by the issuing party, and all the user needs to do is apply them to their chat profile. Note that certain badges may appear/disappear based on the current streamer's chat tktk. + +## Lexicon schemas + +We have three relevant lexicons. + +1. **`place.stream.badge.defs`** - badge definitions and view model + + - defines known badge types: `mod`, `streamer`, `vip` + - `badgeView` object: `{badgeType, issuer, recipient, signature?}` + +2. **`place.stream.badge.issuance`** - record of badge grant + + - stored as atproto record (key: tid) + - issued by streamer or other authorized entity + - example: streamer issues vip badge to a user + +3. **`place.stream.badge.display`** - user's badge selection + - user-controlled record defining which badges to show + - array of up to 3 `badgeSelection` objects + - first slot server-controlled (mod/streamer/staff), second slot is streamer-specific (vip, subscription), third slot is user-set (event, staff2, node subscription, etc.) + +:::note +This may get changed to be in the user's chat profile? Maybe we could have a "main" chat profile and a streamer-specific profile? +::: + +## TODO + +- [ ] implement cryptographic signatures for badge issuance +- [ ] implement badge issuance ui (streamer grants vip badges) +- [ ] implement badge selection ui (users choose which badges to display) +- [ ] add more badge types (subscriber, founder, staff, etc) -- 2.51.2 From a7202425ea337cdcc1090f06efe4b0e20230c9eb Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:41:37 -0600 Subject: [PATCH 03/49] whoops --- js/docs/astro.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/docs/astro.config.mjs b/js/docs/astro.config.mjs index bd138404..de80513b 100644 --- a/js/docs/astro.config.mjs +++ b/js/docs/astro.config.mjs @@ -82,7 +82,7 @@ export default defineConfig({ }, { label: "Features (Dev)", - autogenerate: { directory: "guides/features-dev" }, + autogenerate: { directory: "features-dev" }, }, { label: "Video Metadata", -- 2.51.2 From 4e9d4d578732d78c9ad31454b0e103cbbe70f639 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:39:52 -0600 Subject: [PATCH 04/49] same styles for all images --- js/components/src/components/chat/chat-message.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 0f9d7ab2..fac52d2b 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -178,7 +178,7 @@ export const RenderChatMessage = memo( {badge.badgeType === "place.stream.badge.defs#mod" ? ( ) : badge.badgeType === "place.stream.badge.defs#streamer" ? ( ) : ( - + )} ))} -- 2.51.2 From a6f75b947028df17ea8ecb7e271562b448560ab8 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:55:24 -0600 Subject: [PATCH 05/49] getvalidbadges :) --- .../place-stream-badge-getvalidbadges.md | 76 ++++++++++++++++++ .../content/docs/lex-reference/openapi.json | 65 ++++++++++++++++ .../place/stream/badge/getValidBadges.json | 36 +++++++++ pkg/atproto/badges.go | 40 +++------- pkg/badges/badges.go | 54 +++++++++++++ pkg/badges/badges_test.go | 77 +++++++++++++++++++ pkg/spxrpc/place_stream_badge.go | 29 +++++++ pkg/spxrpc/stubs.go | 15 ++++ pkg/streamplace/badgegetValidBadges.go | 33 ++++++++ 9 files changed, 394 insertions(+), 31 deletions(-) create mode 100644 js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md create mode 100644 lexicons/place/stream/badge/getValidBadges.json create mode 100644 pkg/badges/badges.go create mode 100644 pkg/badges/badges_test.go create mode 100644 pkg/spxrpc/place_stream_badge.go create mode 100644 pkg/streamplace/badgegetValidBadges.go diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md new file mode 100644 index 00000000..8af7904e --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md @@ -0,0 +1,76 @@ +--- +title: place.stream.badge.getValidBadges +description: Reference for the place.stream.badge.getValidBadges lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `query` + +Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| ---------- | -------- | ----- | ------------------------------------------------------------------------ | ------------- | +| `streamer` | `string` | ❌ | Optional DID of the streamer for context-specific badges (mod, vip, etc) | Format: `did` | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| -------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ----------- | +| `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | ✅ | | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.getValidBadges", + "defs": { + "main": { + "type": "query", + "description": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", + "parameters": { + "type": "params", + "properties": { + "streamer": { + "type": "string", + "format": "did", + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["badges"], + "properties": { + "badges": { + "type": "array", + "items": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView" + } + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 7efe3598..59f43337 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -1870,6 +1870,47 @@ } } }, + "/xrpc/place.stream.badge.getValidBadges": { + "get": { + "summary": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", + "operationId": "place.stream.badge.getValidBadges", + "tags": ["place.stream.badge"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "badges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeView" + } + } + }, + "required": ["badges"] + } + } + } + } + }, + "parameters": [ + { + "name": "streamer", + "in": "query", + "required": false, + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)", + "schema": { + "type": "string", + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)", + "format": "did" + } + } + ] + } + }, "/xrpc/com.atproto.sync.getRecord": { "get": { "summary": "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", @@ -3429,6 +3470,30 @@ }, "required": ["key", "mimeType"] }, + "place.stream.badge.defs_badgeView": { + "type": "object", + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", + "properties": { + "badgeType": { + "type": "string" + }, + "issuer": { + "type": "string", + "description": "DID of the badge issuer.", + "format": "did" + }, + "recipient": { + "type": "string", + "description": "DID of the badge recipient.", + "format": "did" + }, + "signature": { + "type": "string", + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." + } + }, + "required": ["badgeType", "issuer", "recipient"] + }, "com.atproto.sync.listRepos_repo": { "type": "object", "properties": { diff --git a/lexicons/place/stream/badge/getValidBadges.json b/lexicons/place/stream/badge/getValidBadges.json new file mode 100644 index 00000000..12ff6556 --- /dev/null +++ b/lexicons/place/stream/badge/getValidBadges.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.getValidBadges", + "defs": { + "main": { + "type": "query", + "description": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", + "parameters": { + "type": "params", + "properties": { + "streamer": { + "type": "string", + "format": "did", + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["badges"], + "properties": { + "badges": { + "type": "array", + "items": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView" + } + } + } + } + } + } + } +} diff --git a/pkg/atproto/badges.go b/pkg/atproto/badges.go index 1f052182..a45f7ca0 100644 --- a/pkg/atproto/badges.go +++ b/pkg/atproto/badges.go @@ -4,8 +4,7 @@ import ( "context" "fmt" - "stream.place/streamplace/pkg/constants" - "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/badges" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/streamplace" ) @@ -21,39 +20,18 @@ func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_ authorDID := message.Author.Did - var badge *streamplace.BadgeDefs_BadgeView - - // Check if author is the streamer - if authorDID == streamerDID { - badge = &streamplace.BadgeDefs_BadgeView{ - BadgeType: constants.BadgeTypeStreamer, - Issuer: issuerDID, - Recipient: authorDID, - } - } else { - // Check if author has any moderation permissions for the streamer - delegations, err := m.GetModerationDelegations(ctx, streamerDID, authorDID) - if err != nil { - log.Error(ctx, "failed to get moderation delegations", "err", err, "authorDID", authorDID, "streamerDID", streamerDID) - return err - } - - // If the author has any delegations (meaning they're a moderator), add a mod badge - if len(delegations) > 0 { - badge = &streamplace.BadgeDefs_BadgeView{ - BadgeType: constants.BadgeTypeMod, - Issuer: issuerDID, - Recipient: authorDID, - } - } + // Get valid badges for this user + validBadges, err := badges.GetValidBadges(ctx, authorDID, streamerDID, issuerDID, m) + if err != nil { + return err } - // Prepend the badge if one was created (server-controlled badge is first) - if badge != nil { + // Prepend server-controlled badges (first badge slot is reserved for server) + if len(validBadges) > 0 { if message.Badges == nil { - message.Badges = []*streamplace.BadgeDefs_BadgeView{badge} + message.Badges = validBadges } else { - message.Badges = append([]*streamplace.BadgeDefs_BadgeView{badge}, message.Badges...) + message.Badges = append(validBadges, message.Badges...) } } diff --git a/pkg/badges/badges.go b/pkg/badges/badges.go new file mode 100644 index 00000000..dc2330d1 --- /dev/null +++ b/pkg/badges/badges.go @@ -0,0 +1,54 @@ +package badges + +import ( + "context" + + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" +) + +// GetValidBadges returns valid badges for a user in the context of a streamer's chat. +// Returns server-controlled badges (streamer, mod) based on permissions. +func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, m model.Model) ([]*streamplace.BadgeDefs_BadgeView, error) { + badges := []*streamplace.BadgeDefs_BadgeView{} + + // If no streamer context, return empty badges + if streamerDID == "" { + return badges, nil + } + + // Check if user is the streamer + if userDID == streamerDID { + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeStreamer, + Issuer: issuerDID, + Recipient: userDID, + }) + return badges, nil + } + + // Check if user has moderation permissions for this streamer + delegations, err := m.GetModerationDelegations(ctx, streamerDID, userDID) + if err != nil { + log.Error(ctx, "failed to get moderation delegations", "err", err, "userDID", userDID, "streamerDID", streamerDID) + return nil, err + } + + // If user has any delegations, they're a moderator + if len(delegations) > 0 { + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeMod, + Issuer: issuerDID, + Recipient: userDID, + }) + } + + // TODO: Add badge issuance records when implemented + // - Query place.stream.badge.issuance records for this user + // - Verify signatures if issuer is not the current node + // - Add VIP badges, subscriber badges, etc. + + return badges, nil +} diff --git a/pkg/badges/badges_test.go b/pkg/badges/badges_test.go new file mode 100644 index 00000000..715d5f35 --- /dev/null +++ b/pkg/badges/badges_test.go @@ -0,0 +1,77 @@ +package badges + +import ( + "context" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/util" + "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" +) + +func TestGetValidBadges(t *testing.T) { + ctx := context.Background() + + mod, err := model.MakeDB(":memory:") + require.NoError(t, err) + + issuerDID := "did:web:node.example.com" + streamerDID := "did:plc:streamer" + moderatorDID := "did:plc:moderator" + regularUserDID := "did:plc:regular" + + t.Run("returns empty when no streamer context", func(t *testing.T) { + badges, err := GetValidBadges(ctx, regularUserDID, "", issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("returns streamer badge for streamer", func(t *testing.T) { + badges, err := GetValidBadges(ctx, streamerDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) + require.Equal(t, issuerDID, badges[0].Issuer) + require.Equal(t, streamerDID, badges[0].Recipient) + }) + + t.Run("returns no badges for regular user", func(t *testing.T) { + badges, err := GetValidBadges(ctx, regularUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("returns mod badge for moderator", func(t *testing.T) { + // Grant moderation permissions + perm := &streamplace.ModerationPermission{ + LexiconTypeID: "place.stream.moderation.permission", + Moderator: moderatorDID, + Permissions: []string{"ban", "hide"}, + CreatedAt: time.Now().Format(util.ISO8601), + } + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") + require.NoError(t, err) + + err = mod.CreateModerationDelegation(ctx, perm, aturi) + require.NoError(t, err) + + badges, err := GetValidBadges(ctx, moderatorDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeMod, badges[0].BadgeType) + require.Equal(t, issuerDID, badges[0].Issuer) + require.Equal(t, moderatorDID, badges[0].Recipient) + }) + + t.Run("streamer badge takes priority over mod", func(t *testing.T) { + // Even if streamer has mod permissions for themselves, they get streamer badge + badges, err := GetValidBadges(ctx, streamerDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) + }) +} diff --git a/pkg/spxrpc/place_stream_badge.go b/pkg/spxrpc/place_stream_badge.go new file mode 100644 index 00000000..19167e44 --- /dev/null +++ b/pkg/spxrpc/place_stream_badge.go @@ -0,0 +1,29 @@ +package spxrpc + +import ( + "context" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/streamplace/oatproxy/pkg/oatproxy" + "stream.place/streamplace/pkg/badges" + placestream "stream.place/streamplace/pkg/streamplace" +) + +func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context, streamer string) (*placestream.BadgeGetValidBadges_Output, error) { + // Get authenticated user's DID from OAuth session + session, _ := oatproxy.GetOAuthSession(ctx) + if session == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") + } + + // Get valid badges using shared badge logic + badgeList, err := badges.GetValidBadges(ctx, session.DID, streamer, s.cli.MyDID(), s.model) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to get valid badges") + } + + return &placestream.BadgeGetValidBadges_Output{ + Badges: badgeList, + }, nil +} diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 026f74c4..5490dc1a 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -278,6 +278,7 @@ func (s *Server) HandleComAtprotoSyncListRepos(c echo.Context) error { } func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { + e.GET("/xrpc/place.stream.badge.getValidBadges", s.HandlePlaceStreamBadgeGetValidBadges) e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob) e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob) e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding) @@ -308,6 +309,20 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { return nil } +func (s *Server) HandlePlaceStreamBadgeGetValidBadges(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBadgeGetValidBadges") + defer span.End() + streamer := c.QueryParam("streamer") + var out *placestream.BadgeGetValidBadges_Output + var handleErr error + // func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context,streamer string) (*placestream.BadgeGetValidBadges_Output, error) + out, handleErr = s.handlePlaceStreamBadgeGetValidBadges(ctx, streamer) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingDeleteBlob") defer span.End() diff --git a/pkg/streamplace/badgegetValidBadges.go b/pkg/streamplace/badgegetValidBadges.go new file mode 100644 index 00000000..6a877a6a --- /dev/null +++ b/pkg/streamplace/badgegetValidBadges.go @@ -0,0 +1,33 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.getValidBadges + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// BadgeGetValidBadges_Output is the output of a place.stream.badge.getValidBadges call. +type BadgeGetValidBadges_Output struct { + Badges []*BadgeDefs_BadgeView `json:"badges" cborgen:"badges"` +} + +// BadgeGetValidBadges calls the XRPC method "place.stream.badge.getValidBadges". +// +// streamer: Optional DID of the streamer for context-specific badges (mod, vip, etc) +func BadgeGetValidBadges(ctx context.Context, c lexutil.LexClient, streamer string) (*BadgeGetValidBadges_Output, error) { + var out BadgeGetValidBadges_Output + + params := map[string]interface{}{} + if streamer != "" { + params["streamer"] = streamer + } + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.badge.getValidBadges", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} -- 2.51.2 From 2c490cd49d8ed15464e33f273cb40b8b4258a1b2 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:02:51 -0600 Subject: [PATCH 06/49] update badge test --- pkg/atproto/badges_test.go | 11 ++++++----- pkg/constants/constants.go | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/atproto/badges_test.go b/pkg/atproto/badges_test.go index b06b259e..0102f49e 100644 --- a/pkg/atproto/badges_test.go +++ b/pkg/atproto/badges_test.go @@ -9,6 +9,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/util" "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/streamplace" ) @@ -51,7 +52,7 @@ func TestAddModBadge(t *testing.T) { err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) require.NoError(t, err) require.Len(t, msg.Badges, 1, "should have 1 badge when user is the streamer") - require.Equal(t, "place.stream.badge.defs#streamer", msg.Badges[0].BadgeType) + require.Equal(t, constants.BadgeTypeStreamer, msg.Badges[0].BadgeType) require.Equal(t, issuerDID, msg.Badges[0].Issuer) require.Equal(t, streamerDID, msg.Badges[0].Recipient) }) @@ -75,7 +76,7 @@ func TestAddModBadge(t *testing.T) { err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) require.NoError(t, err) require.Len(t, msg.Badges, 1, "should have 1 badge when user is a moderator") - require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType) + require.Equal(t, constants.BadgeTypeMod, msg.Badges[0].BadgeType) require.Equal(t, issuerDID, msg.Badges[0].Issuer) require.Equal(t, moderatorDID, msg.Badges[0].Recipient) }) @@ -85,7 +86,7 @@ func TestAddModBadge(t *testing.T) { msg := *message // copy msg.Badges = []*streamplace.BadgeDefs_BadgeView{ { - BadgeType: "place.stream.badges.badge#vip", + BadgeType: constants.BadgeTypeVIP, Issuer: "did:web:other.com", Recipient: moderatorDID, }, @@ -94,7 +95,7 @@ func TestAddModBadge(t *testing.T) { err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) require.NoError(t, err) require.Len(t, msg.Badges, 2, "should have 2 badges") - require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType, "mod badge should be first") - require.Equal(t, "place.stream.badges.badge#vip", msg.Badges[1].BadgeType, "vip badge should be second") + require.Equal(t, constants.BadgeTypeMod, msg.Badges[0].BadgeType, "mod badge should be first") + require.Equal(t, constants.BadgeTypeVIP, msg.Badges[1].BadgeType, "vip badge should be second") }) } diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 439150e8..1b298884 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -19,6 +19,7 @@ var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" // const ( BadgeTypeMod = "place.stream.badge.defs#mod" BadgeTypeStreamer = "place.stream.badge.defs#streamer" + BadgeTypeVIP = "place.stream.badge.defs#vip" ) const DID_KEY_PREFIX = "did:key" //nolint:all -- 2.51.2 From 4d6b1e106802c80e9675b01d45b56a93ecafaa84 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 16:32:49 -0800 Subject: [PATCH 07/49] redcircle: render profile picture --- pkg/redcircle/.gitignore | 1 + pkg/redcircle/redcircle.go | 51 ++++++++++++++++++++++++++++++++ pkg/redcircle/redcircle.png | Bin 0 -> 26138 bytes pkg/redcircle/redcircle_test.go | 22 ++++++++++++++ pkg/redcircle/robot.jpg | Bin 0 -> 18830 bytes 5 files changed, 74 insertions(+) create mode 100644 pkg/redcircle/.gitignore create mode 100644 pkg/redcircle/redcircle.go create mode 100644 pkg/redcircle/redcircle.png create mode 100644 pkg/redcircle/redcircle_test.go create mode 100644 pkg/redcircle/robot.jpg diff --git a/pkg/redcircle/.gitignore b/pkg/redcircle/.gitignore new file mode 100644 index 00000000..a25287a1 --- /dev/null +++ b/pkg/redcircle/.gitignore @@ -0,0 +1 @@ +test.jpg diff --git a/pkg/redcircle/redcircle.go b/pkg/redcircle/redcircle.go new file mode 100644 index 00000000..07cd5d75 --- /dev/null +++ b/pkg/redcircle/redcircle.go @@ -0,0 +1,51 @@ +package redcircle + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/renderers" +) + +const WIDTH = 1000.0 +const HEIGHT = 1000.0 + +const BORDER_SIZE = 62.0 + +//go:embed redcircle.png +var RedCirclePNG []byte + +func GenerateRedCircle(ctx context.Context, profileJPEG []byte) ([]byte, error) { + c := canvas.New(WIDTH, HEIGHT) + reader := bytes.NewReader(RedCirclePNG) + img, _, err := image.Decode(reader) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + profileReader := bytes.NewReader(profileJPEG) + profileImg, _, err := image.Decode(profileReader) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + scaleFactor := WIDTH / (WIDTH - BORDER_SIZE*2) + + // Draw the decoded image onto the canvas + canvasCtx := canvas.NewContext(c) + canvasCtx.DrawImage(BORDER_SIZE, BORDER_SIZE, profileImg, canvas.DPMM(scaleFactor)) + canvasCtx.DrawImage(0, 0, img, canvas.DPMM(1)) + + var buf bytes.Buffer + + if err := c.Write(&buf, renderers.JPEG()); err != nil { + return nil, fmt.Errorf("failed to render to JPEG: %w", err) + } + return buf.Bytes(), nil +} diff --git a/pkg/redcircle/redcircle.png b/pkg/redcircle/redcircle.png new file mode 100644 index 0000000000000000000000000000000000000000..64ef64e36cdb47fef6c054c6dd2bd7fa54a1804c GIT binary patch literal 26138 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 literal 0 HcmV?d00001 diff --git a/pkg/redcircle/redcircle_test.go b/pkg/redcircle/redcircle_test.go new file mode 100644 index 00000000..45863d40 --- /dev/null +++ b/pkg/redcircle/redcircle_test.go @@ -0,0 +1,22 @@ +package redcircle + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateRedCircle(t *testing.T) { + bs, err := os.ReadFile("robot.jpg") + require.NoError(t, err) + img, err := GenerateRedCircle(context.Background(), bs) + require.NoError(t, err) + require.NotNil(t, img) + f, err := os.Create("test.jpg") + require.NoError(t, err) + defer f.Close() + _, err = f.Write(img) + require.NoError(t, err) +} diff --git a/pkg/redcircle/robot.jpg b/pkg/redcircle/robot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..089026d64fe1bb58be73967743fb9dc58f4f5364 GIT binary patch literal 18830 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 literal 0 HcmV?d00001 -- 2.51.2 From c300a120171abaf4f6722b3ebd638b9793ad85f2 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 17:38:02 -0800 Subject: [PATCH 08/49] redcircle: profile picture updated!!! --- pkg/atproto/profile.go | 45 +++++++++ pkg/cmd/streamplace.go | 2 +- pkg/director/director.go | 6 +- pkg/director/stream_session.go | 11 +++ pkg/director/stream_session_redcircle.go | 120 +++++++++++++++++++++++ pkg/spxrpc/og.go | 38 +------ 6 files changed, 183 insertions(+), 39 deletions(-) create mode 100644 pkg/atproto/profile.go create mode 100644 pkg/director/stream_session_redcircle.go diff --git a/pkg/atproto/profile.go b/pkg/atproto/profile.go new file mode 100644 index 00000000..cdfd7739 --- /dev/null +++ b/pkg/atproto/profile.go @@ -0,0 +1,45 @@ +package atproto + +import ( + "context" + "errors" + "fmt" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/xrpc" +) + +var ErrUserNotFound = errors.New("user not found") + +func (atsync *ATProtoSynchronizer) FetchUserProfile(ctx context.Context, username string) (*bsky.ActorDefs_ProfileViewDetailed, error) { + // Use ATSync to resolve username to DID, then fetch full profile from Bluesky + var actor string + + // First try to resolve via internal DB + repo, err := atsync.Model.GetRepoByHandleOrDID(username) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUserNotFound, err) + } else if repo != nil { + // Use the DID as it's the most reliable identifier + actor = repo.DID + } else { + return nil, fmt.Errorf("no repo found for username: %s (%w)", username, ErrUserNotFound) + } + + // Fetch full profile from Bluesky public API + client := &xrpc.Client{ + Host: "https://public.api.bsky.app", + } + + profile, err := bsky.ActorGetProfile(ctx, client, actor) + if err != nil { + return nil, fmt.Errorf("failed to fetch profile from Bluesky for '%s': %w", actor, err) + } + + if profile == nil { + return nil, fmt.Errorf("received nil profile from Bluesky API for '%s'", actor) + } + + return profile, nil + +} diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index c82df90c..74c6fb78 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -318,7 +318,7 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu Public: cli.PublicOAuth, HTTPClient: &aqhttp.Client, }) - d := director.NewDirector(mm, mod, cli, b, op, state, replicator, ldb) + d := director.NewDirector(mm, mod, cli, b, op, state, replicator, ldb, atsync) a, err := api.MakeStreamplaceAPI(cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) if err != nil { return err diff --git a/pkg/director/director.go b/pkg/director/director.go index c12d22bf..129c5599 100644 --- a/pkg/director/director.go +++ b/pkg/director/director.go @@ -7,6 +7,7 @@ import ( "github.com/streamplace/oatproxy/pkg/oatproxy" "golang.org/x/sync/errgroup" + "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/bus" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/localdb" @@ -34,9 +35,10 @@ type Director struct { statefulDB *statedb.StatefulDB replicator replication.Replicator localDB localdb.LocalDB + atsync *atproto.ATProtoSynchronizer } -func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator, ldb localdb.LocalDB) *Director { +func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator, ldb localdb.LocalDB, atsync *atproto.ATProtoSynchronizer) *Director { return &Director{ mm: mm, mod: mod, @@ -48,6 +50,7 @@ func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus * statefulDB: statefulDB, replicator: replicator, localDB: ldb, + atsync: atsync, } } @@ -83,6 +86,7 @@ func (d *Director) Start(ctx context.Context) error { statusUpdateChan: make(chan struct{}, 1), originUpdateChan: make(chan struct{}, 1), localDB: d.localDB, + atsync: d.atsync, } d.streamSessions[not.Segment.RepoDID] = ss g.Go(func() error { diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index d34ba099..37d5faf9 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -17,6 +17,7 @@ import ( "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/aqtime" + "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/bus" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/livepeer" @@ -57,6 +58,7 @@ type StreamSession struct { packets []bus.PacketizedSegment statefulDB *statedb.StatefulDB replicator replication.Replicator + atsync *atproto.ATProtoSynchronizer } func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotification) error { @@ -129,6 +131,15 @@ func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotif }) } + ss.Go(ctx, func() error { + err := ss.UpdateProfilePicture(ctx, spseg.Creator) + if err != nil { + log.Error(ctx, "failed to update profile picture", "error", err) + } + <-ctx.Done() + return nil + }) + for { select { case <-ss.segmentChan: diff --git a/pkg/director/stream_session_redcircle.go b/pkg/director/stream_session_redcircle.go new file mode 100644 index 00000000..5b50d653 --- /dev/null +++ b/pkg/director/stream_session_redcircle.go @@ -0,0 +1,120 @@ +package director + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/api/bsky" + lexutil "github.com/bluesky-social/indigo/lex/util" + + "github.com/bluesky-social/indigo/xrpc" + "stream.place/streamplace/pkg/aqhttp" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/redcircle" +) + +func downloadImage(ctx context.Context, url string) ([]byte, error) { + if url == "" { + return nil, errors.New("empty URL provided") + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := aqhttp.Do(ctx, req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) + } + + imageData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read image data: %w", err) + } + + return imageData, nil +} + +func (ss *StreamSession) UpdateProfilePicture(ctx context.Context, repoDID string) error { + session, err := ss.statefulDB.GetSessionByDID(ss.repoDID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) + } + if session == nil { + return fmt.Errorf("no session found for repoDID: %s", ss.repoDID) + } + session, err = ss.op.RefreshIfNeeded(session) + if err != nil { + return fmt.Errorf("failed to refresh session: %w", err) + } + client, err := ss.op.GetXrpcClient(session) + if err != nil { + return fmt.Errorf("failed to get xrpc client: %w", err) + } + profile, err := ss.atsync.FetchUserProfile(ctx, repoDID) + if err != nil { + return fmt.Errorf("failed to fetch user profile: %w", err) + } + if profile == nil { + return fmt.Errorf("received nil profile from Bluesky API for '%s'", repoDID) + } + if profile.Avatar == nil || *profile.Avatar == "" { + return fmt.Errorf("received nil or empty avatar from Bluesky API for '%s'", repoDID) + } + profileAvatar := *profile.Avatar + imageData, err := downloadImage(ctx, profileAvatar) + if err != nil { + return fmt.Errorf("failed to download image: %w", err) + } + circleImage, err := redcircle.GenerateRedCircle(ctx, imageData) + if err != nil { + return fmt.Errorf("failed to generate redcircle: %w", err) + } + var out comatproto.RepoUploadBlob_Output + err = client.Do(ctx, xrpc.Procedure, "image/jpeg", "com.atproto.repo.uploadBlob", nil, bytes.NewReader(circleImage), &out) + if err != nil { + return fmt.Errorf("failed to upload blob: %w", err) + } + if out.Blob.Size == 0 { + return fmt.Errorf("uploaded blob has size 0") + } + var profileRecord comatproto.RepoGetRecord_Output + err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ + "repo": repoDID, + "collection": "app.bsky.actor.profile", + "rkey": "self", + }, nil, &profileRecord) + if err != nil { + return fmt.Errorf("failed to get profile record: %w", err) + } + actorProfile, ok := profileRecord.Value.Val.(*bsky.ActorProfile) + if !ok { + return fmt.Errorf("profile record is not a actor profile") + } + actorProfile.Avatar = out.Blob + inp := comatproto.RepoPutRecord_Input{ + Collection: "app.bsky.actor.profile", + Record: &lexutil.LexiconTypeDecoder{Val: actorProfile}, + Rkey: "self", + Repo: repoDID, + SwapRecord: profileRecord.Cid, + } + putOutput := comatproto.RepoPutRecord_Output{} + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &putOutput) + if err != nil { + return fmt.Errorf("failed to put profile record: %w", err) + } + log.Log(ctx, "uploaded redcircle", "ref", out.Blob.Ref.String(), "size", out.Blob.Size) + return nil +} diff --git a/pkg/spxrpc/og.go b/pkg/spxrpc/og.go index 9211d199..a51b1e8f 100644 --- a/pkg/spxrpc/og.go +++ b/pkg/spxrpc/og.go @@ -20,8 +20,6 @@ import ( "golang.org/x/image/draw" - "github.com/bluesky-social/indigo/api/bsky" - "github.com/bluesky-social/indigo/xrpc" "github.com/labstack/echo/v4" "github.com/patrickmn/go-cache" "github.com/tdewolff/canvas" @@ -90,8 +88,6 @@ const ( descriptionTruncate = 117 ) -var ErrUserNotFound = errors.New("user not found") - // blendWithBackground creates a pseudo-transparent color by blending the given color with the background // alpha should be between 0.0 (fully background) and 1.0 (fully foreground color) func blendWithBackground(fg color.RGBA, bg color.RGBA, alpha float64) color.RGBA { @@ -235,7 +231,7 @@ func (s *Server) generateOGImage(ctx context.Context, username string) ([]byte, handle = username description = "Live streaming platform for creators and their communities." - profileData, err := s.fetchUserProfile(ctx, username) + profileData, err := s.ATSync.FetchUserProfile(ctx, username) if err != nil { return nil, fmt.Errorf("failed to fetch profile, because %w", err) } else if profileData != nil { @@ -513,35 +509,3 @@ func getAtkinsonBold() ([]byte, error) { return data, nil } - -func (s *Server) fetchUserProfile(ctx context.Context, username string) (*bsky.ActorDefs_ProfileViewDetailed, error) { - // Use ATSync to resolve username to DID, then fetch full profile from Bluesky - var actor string - - // First try to resolve via internal DB - repo, err := s.ATSync.Model.GetRepoByHandleOrDID(username) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrUserNotFound, err) - } else if repo != nil { - // Use the DID as it's the most reliable identifier - actor = repo.DID - } else { - return nil, fmt.Errorf("no repo found for username: %s (%w)", username, ErrUserNotFound) - } - - // Fetch full profile from Bluesky public API - client := &xrpc.Client{ - Host: "https://public.api.bsky.app", - } - - profile, err := bsky.ActorGetProfile(ctx, client, actor) - if err != nil { - return nil, fmt.Errorf("failed to fetch profile from Bluesky for '%s': %w", actor, err) - } - - if profile == nil { - return nil, fmt.Errorf("received nil profile from Bluesky API for '%s'", actor) - } - - return profile, nil -} -- 2.51.2 From 8b5f7a088f7d00587f25a37e1e66a98f730a943b Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 20:53:59 -0800 Subject: [PATCH 09/49] redcircle: index bsky profiles and whatnot --- .../live-dashboard/livestream-panel.tsx | 34 ++- .../src/streamplace-store/stream.tsx | 6 + .../lex-reference/place-stream-livestream.md | 35 +++ lexicons/place/stream/livestream.json | 19 ++ pkg/atproto/firehose.go | 3 + pkg/atproto/lexicon_permission_sets.go | 1 + pkg/atproto/redcircle.go | 180 ++++++++++++++ pkg/{redcircle => atproto}/redcircle.png | Bin pkg/{redcircle => atproto}/redcircle_test.go | 2 +- pkg/{redcircle => atproto}/robot.jpg | Bin pkg/atproto/sync.go | 49 +++- pkg/atproto/test.jpg | Bin 0 -> 73910 bytes pkg/cmd/streamplace.go | 31 ++- pkg/constants/constants.go | 3 + pkg/director/stream_session.go | 16 +- pkg/director/stream_session_redcircle.go | 119 --------- pkg/gen/gen.go | 1 + pkg/model/bsky_profile.go | 69 ++++++ pkg/model/model.go | 4 + pkg/redcircle/redcircle.go | 51 ---- pkg/spid/cid.go | 8 +- pkg/streamplace/cbor_gen.go | 231 +++++++++++++++++- pkg/streamplace/streamlivestream.go | 11 +- 23 files changed, 660 insertions(+), 213 deletions(-) create mode 100644 pkg/atproto/redcircle.go rename pkg/{redcircle => atproto}/redcircle.png (100%) rename pkg/{redcircle => atproto}/redcircle_test.go (95%) rename pkg/{redcircle => atproto}/robot.jpg (100%) create mode 100644 pkg/atproto/test.jpg create mode 100644 pkg/model/bsky_profile.go delete mode 100644 pkg/redcircle/redcircle.go diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index d34a07f5..aaa9e6c3 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -19,6 +19,7 @@ import { useUrl, zero, } from "@streamplace/components"; +import { error } from "console"; import { ArrowRight, ImagePlus, X } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -224,6 +225,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { const [createPost, setCreatePost] = useState(true); const [sendPushNotification, setSendPushNotification] = useState(true); const [canonicalUrl, setCanonicalUrl] = useState(""); + const [updateBskyProfile, setUpdateBskyProfile] = useState(true); const defaultCanonicalUrl = useMemo(() => { return `${url}/${profile && formatHandle(profile)}`; }, [url, profile?.handle]); @@ -256,7 +258,14 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { ); } - // Prefill post creation preference + if ( + typeof livestream.record.integrationSettings?.updateBskyProfile === + "boolean" + ) { + setUpdateBskyProfile( + livestream.record.integrationSettings.updateBskyProfile, + ); + } setCreatePost(typeof livestream.record.post !== "undefined"); }, [livestream, defaultCanonicalUrl]); @@ -295,6 +304,9 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { notificationSettings: { pushNotification: sendPushNotification, }, + integrationSettings: { + updateBskyProfile: updateBskyProfile, + }, canonicalUrl: canonicalUrl || undefined, }); } else { @@ -343,7 +355,6 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { title, selectedImage, mode, - captureFrame, createStreamRecord, updateStreamRecord, livestream, @@ -359,9 +370,12 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { if (file) { setSelectedImage(file); } + console.error("Failed to fetch last image:", error); + toast.show("Error", "Failed to load previous thumbnail", { + duration: 3, + }); }; - input.click(); - }, []); + }, [livestream, toast]); const handleImageRemove = useCallback(() => { setSelectedImage(undefined); @@ -626,6 +640,18 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { style={[{ fontSize: 12 }]} /> + + + setUpdateBskyProfile(checked)} + label={"Update Bluesky avatar"} + style={[{ fontSize: 12 }]} + /> + {/* Image upload for create mode */} diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index 07af76e5..9c387c71 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -134,12 +134,14 @@ export function useCreateStreamRecord() { submitPost, canonicalUrl, notificationSettings, + integrationSettings, }: { title: string; customThumbnail?: Blob; submitPost?: boolean; canonicalUrl?: string; notificationSettings?: PlaceStreamLivestream.NotificationSettings; + integrationSettings?: PlaceStreamLivestream.IntegrationSettings; }) => { if (typeof submitPost !== "boolean") { submitPost = true; @@ -268,6 +270,10 @@ export function useCreateStreamRecord() { record.notificationSettings = notificationSettings; } + if (integrationSettings) { + record.integrationSettings = integrationSettings; + } + await agent.com.atproto.repo.createRecord({ repo: agent.did, collection: "place.stream.livestream", diff --git a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md index a6f8c95f..2c28c6f7 100644 --- a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md +++ b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md @@ -24,11 +24,13 @@ Record announcing a livestream is happening | `title` | `string` | ✅ | The title of the livestream, as it will be announced to followers. | Max Length: 1400
Max Graphemes: 140 | | `url` | `string` | ❌ | The URL where this stream can be found. This is primarily a hint for other Streamplace nodes to locate and replicate the stream. | Format: `uri` | | `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` | +| `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the user. | Format: `datetime` | | `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | | | `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: ` / ` e.g. Streamplace/0.7.5 iOS | | | `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | | `thumb` | `blob` | ❌ | | Accept: `image/*`
Max Size: 1000000 bytes | | `notificationSettings` | [`place.stream.livestream#notificationSettings`](/lex-reference/place-stream-livestream#notificationsettings) | ❌ | | | +| `integrationSettings` | [`place.stream.livestream#integrationSettings`](/lex-reference/place-stream-livestream#integrationsettings) | ❌ | | | --- @@ -46,6 +48,20 @@ Record announcing a livestream is happening --- + + +### `integrationSettings` + +**Type:** `object` + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ------------------- | --------- | ----- | -------------------------------------------------------------------------------- | ----------- | +| `updateBskyProfile` | `boolean` | ❌ | Whether this livestream should update the Bluesky profile picture & pinned post. | | + +--- + ### `livestreamView` @@ -157,6 +173,11 @@ Record announcing a livestream is happening "format": "datetime", "description": "Client-declared timestamp when this livestream started." }, + "lastSeenAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this livestream was last seen by the user." + }, "post": { "type": "ref", "ref": "com.atproto.repo.strongRef", @@ -179,6 +200,10 @@ Record announcing a livestream is happening "notificationSettings": { "type": "ref", "ref": "place.stream.livestream#notificationSettings" + }, + "integrationSettings": { + "type": "ref", + "ref": "place.stream.livestream#integrationSettings" } } } @@ -193,6 +218,16 @@ Record announcing a livestream is happening } } }, + "integrationSettings": { + "type": "object", + "required": [], + "properties": { + "updateBskyProfile": { + "type": "boolean", + "description": "Whether this livestream should update the Bluesky profile picture & pinned post." + } + } + }, "livestreamView": { "type": "object", "required": ["uri", "cid", "author", "record", "indexedAt"], diff --git a/lexicons/place/stream/livestream.json b/lexicons/place/stream/livestream.json index d453ceea..8814416c 100644 --- a/lexicons/place/stream/livestream.json +++ b/lexicons/place/stream/livestream.json @@ -26,6 +26,11 @@ "format": "datetime", "description": "Client-declared timestamp when this livestream started." }, + "lastSeenAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this livestream was last seen by the user." + }, "post": { "type": "ref", "ref": "com.atproto.repo.strongRef", @@ -48,6 +53,10 @@ "notificationSettings": { "type": "ref", "ref": "place.stream.livestream#notificationSettings" + }, + "integrationSettings": { + "type": "ref", + "ref": "place.stream.livestream#integrationSettings" } } } @@ -62,6 +71,16 @@ } } }, + "integrationSettings": { + "type": "object", + "required": [], + "properties": { + "updateBskyProfile": { + "type": "boolean", + "description": "Whether this livestream should update the Bluesky profile picture & pinned post." + } + } + }, "livestreamView": { "type": "object", "required": ["uri", "cid", "author", "record", "indexedAt"], diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go index 77823bcc..8e7ca0be 100644 --- a/pkg/atproto/firehose.go +++ b/pkg/atproto/firehose.go @@ -18,6 +18,7 @@ import ( lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/repo" "github.com/bluesky-social/indigo/repomgr" + "github.com/streamplace/oatproxy/pkg/oatproxy" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/aqtime" @@ -44,6 +45,7 @@ type ATProtoSynchronizer struct { Bus *bus.Bus PLCDirectory identity.Directory CachedPLCDirectory identity.Directory + OATProxy *oatproxy.OATProxy } func (atsync *ATProtoSynchronizer) StartFirehose(ctx context.Context) error { @@ -161,6 +163,7 @@ var CollectionFilter = []string{ constants.APP_BSKY_GRAPH_FOLLOW, constants.APP_BSKY_FEED_POST, constants.APP_BSKY_GRAPH_BLOCK, + constants.APP_BSKY_ACTOR_PROFILE, constants.PLACE_STREAM_LIVE_RECOMMENDATIONS, } diff --git a/pkg/atproto/lexicon_permission_sets.go b/pkg/atproto/lexicon_permission_sets.go index 47731fac..679b4cc3 100644 --- a/pkg/atproto/lexicon_permission_sets.go +++ b/pkg/atproto/lexicon_permission_sets.go @@ -31,6 +31,7 @@ func generatePermissionSets(ctx context.Context, lexs []*lexicon.SchemaFile) ([] "repo?collection=app.bsky.actor.status", "repo?collection=app.bsky.graph.block", "repo?collection=app.bsky.graph.follow", + "repo?collection=app.bsky.actor.profile", "rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", "rpc:app.bsky.actor.getProfiles?aud=did:web:api.bsky.app%23bsky_appview", "include:place.stream.authFull", diff --git a/pkg/atproto/redcircle.go b/pkg/atproto/redcircle.go new file mode 100644 index 00000000..a05a50a5 --- /dev/null +++ b/pkg/atproto/redcircle.go @@ -0,0 +1,180 @@ +package atproto + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/atdata" + + "github.com/bluesky-social/indigo/xrpc" + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/renderers" + "stream.place/streamplace/pkg/aqhttp" + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/log" +) + +const WIDTH = 1000.0 +const HEIGHT = 1000.0 + +const BORDER_SIZE = 62.0 + +//go:embed redcircle.png +var RedCirclePNG []byte + +func GenerateRedCircle(ctx context.Context, profileJPEG []byte) ([]byte, error) { + c := canvas.New(WIDTH, HEIGHT) + reader := bytes.NewReader(RedCirclePNG) + img, _, err := image.Decode(reader) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + profileReader := bytes.NewReader(profileJPEG) + profileImg, _, err := image.Decode(profileReader) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + scaleFactor := WIDTH / (WIDTH - BORDER_SIZE*2) + + // Draw the decoded image onto the canvas + canvasCtx := canvas.NewContext(c) + canvasCtx.DrawImage(BORDER_SIZE, BORDER_SIZE, profileImg, canvas.DPMM(scaleFactor)) + canvasCtx.DrawImage(0, 0, img, canvas.DPMM(1)) + + var buf bytes.Buffer + + if err := c.Write(&buf, renderers.JPEG()); err != nil { + return nil, fmt.Errorf("failed to render to JPEG: %w", err) + } + return buf.Bytes(), nil +} + +func downloadImage(ctx context.Context, url string) ([]byte, error) { + if url == "" { + return nil, errors.New("empty URL provided") + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := aqhttp.Do(ctx, req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) + } + + imageData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read image data: %w", err) + } + + return imageData, nil +} + +func (atsync *ATProtoSynchronizer) UpdateProfilePicture(ctx context.Context, repoDID string) error { + session, err := atsync.StatefulDB.GetSessionByDID(repoDID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) + } + if session == nil { + return fmt.Errorf("no session found for repoDID: %s", repoDID) + } + session, err = atsync.OATProxy.RefreshIfNeeded(session) + if err != nil { + return fmt.Errorf("failed to refresh session: %w", err) + } + client, err := atsync.OATProxy.GetXrpcClient(session) + if err != nil { + return fmt.Errorf("failed to get xrpc client: %w", err) + } + + oldProfile, err := atsync.Model.GetBskyProfile(ctx, repoDID, false) + if err != nil { + return fmt.Errorf("failed to get old profile: %w", err) + } + if oldProfile == nil { + return fmt.Errorf("no old profile found for repoDID: %s", repoDID) + } + if oldProfile.Avatar == nil { + return fmt.Errorf("no avatar found for old profile") + } + + repo, err := atsync.Model.GetRepo(repoDID) + if err != nil { + return fmt.Errorf("failed to get repo: %w", err) + } + if repo == nil { + return fmt.Errorf("no repo found for repoDID: %s", repoDID) + } + + // https://iameli.com/xrpc/com.atproto.sync.getBlob?did=did:plc:2zmxikig2sj7gqaezl5gntae&cid=bafkreia2myy6nak4daop6dnny76ntilhel5wxpcejmvmwj5t3i3npcfifa + avatarURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", repo.PDS, repoDID, oldProfile.Avatar.Ref.String()) + + imageData, err := downloadImage(ctx, avatarURL) + if err != nil { + return fmt.Errorf("failed to download image: %w", err) + } + circleImage, err := GenerateRedCircle(ctx, imageData) + if err != nil { + return fmt.Errorf("failed to generate redcircle: %w", err) + } + var out comatproto.RepoUploadBlob_Output + err = client.Do(ctx, xrpc.Procedure, "image/jpeg", "com.atproto.repo.uploadBlob", nil, bytes.NewReader(circleImage), &out) + if err != nil { + return fmt.Errorf("failed to upload blob: %w", err) + } + if out.Blob.Size == 0 { + return fmt.Errorf("uploaded blob has size 0") + } + var getRecordOut comatproto.RepoGetRecord_Output + err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ + "repo": repoDID, + "collection": "app.bsky.actor.profile", + "rkey": "self", + }, nil, &getRecordOut) + if err != nil { + return fmt.Errorf("failed to get profile record: %w", err) + } + oldProfile.Avatar = out.Blob + buf := bytes.Buffer{} + err = oldProfile.MarshalCBOR(&buf) + if err != nil { + return fmt.Errorf("failed to marshal profile record: %w", err) + } + kv, err := atdata.UnmarshalCBOR(buf.Bytes()) + if err != nil { + return fmt.Errorf("failed to unmarhsal record CBOR: %w", err) + } + kv[constants.BlueskyProfileGoliveKey] = true + inp := map[string]any{ + "collection": "app.bsky.actor.profile", + "record": kv, + "rkey": "self", + "repo": repoDID, + "swapRecord": getRecordOut.Cid, + } + putOutput := comatproto.RepoPutRecord_Output{} + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &putOutput) + if err != nil { + return fmt.Errorf("failed to put profile record: %w", err) + } + log.Log(ctx, "uploaded redcircle", "ref", out.Blob.Ref.String(), "size", out.Blob.Size) + return nil +} diff --git a/pkg/redcircle/redcircle.png b/pkg/atproto/redcircle.png similarity index 100% rename from pkg/redcircle/redcircle.png rename to pkg/atproto/redcircle.png diff --git a/pkg/redcircle/redcircle_test.go b/pkg/atproto/redcircle_test.go similarity index 95% rename from pkg/redcircle/redcircle_test.go rename to pkg/atproto/redcircle_test.go index 45863d40..f4cd1fc6 100644 --- a/pkg/redcircle/redcircle_test.go +++ b/pkg/atproto/redcircle_test.go @@ -1,4 +1,4 @@ -package redcircle +package atproto import ( "context" diff --git a/pkg/redcircle/robot.jpg b/pkg/atproto/robot.jpg similarity index 100% rename from pkg/redcircle/robot.jpg rename to pkg/atproto/robot.jpg diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index d7ca198a..11477434 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -13,6 +13,7 @@ import ( "github.com/bluesky-social/indigo/atproto/atdata" "github.com/bluesky-social/indigo/atproto/syntax" "stream.place/streamplace/pkg/aqtime" + "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/statedb" @@ -83,6 +84,17 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } go atsync.Bus.Publish(userDID, streamplaceBlock) + case *bsky.ActorProfile: + if r == nil { + // someone we don't know about + return nil + } + wasStreamplace, _ := d[constants.BlueskyProfileGoliveKey].(bool) + err := atsync.Model.UpsertBskyProfile(ctx, aturi, *recCBOR, wasStreamplace) + if err != nil { + return fmt.Errorf("failed to upsert bsky profile: %w", err) + } + case *streamplace.ChatMessage: repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) if err != nil { @@ -362,22 +374,33 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } } - task := &statedb.NotificationTask{ - Livestream: lsv, - FeedPost: postView, - PDSURL: r.PDS, - } + if !isUpdate && !isFirstSync { + task := &statedb.NotificationTask{ + Livestream: lsv, + FeedPost: postView, + PDSURL: r.PDS, + } - cp, err := atsync.Model.GetChatProfile(ctx, userDID) - if err != nil { - return fmt.Errorf("failed to get chat profile: %w", err) - } - if cp != nil { - spcp, err := cp.ToStreamplaceChatProfile() + cp, err := atsync.Model.GetChatProfile(ctx, userDID) if err != nil { - return fmt.Errorf("failed to convert chat profile to streamplace chat profile: %w", err) + return fmt.Errorf("failed to get chat profile: %w", err) + } + if cp != nil { + spcp, err := cp.ToStreamplaceChatProfile() + if err != nil { + return fmt.Errorf("failed to convert chat profile to streamplace chat profile: %w", err) + } + task.ChatProfile = spcp + } + + if rec.IntegrationSettings != nil && rec.IntegrationSettings.UpdateBskyProfile != nil && *rec.IntegrationSettings.UpdateBskyProfile { + // TODO DONTMERGE: move this to a background job + log.Warn(ctx, "updating bluesky profile picture", "userDID", userDID) + err = atsync.UpdateProfilePicture(ctx, userDID) + if err != nil { + return fmt.Errorf("failed to update profile picture: %w", err) + } } - task.ChatProfile = spcp } case *streamplace.LiveTeleport: diff --git a/pkg/atproto/test.jpg b/pkg/atproto/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1bfdbcf7deb1f94a439869a3548e0183f252621e GIT binary patch literal 73910 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 z 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 literal 0 HcmV?d00001 diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 74c6fb78..a72a9a18 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -266,6 +266,24 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu } } + op := oatproxy.New(&oatproxy.Config{ + Host: host, + CreateOAuthSession: state.CreateOAuthSession, + UpdateOAuthSession: state.UpdateOAuthSession, + GetOAuthSession: state.LoadOAuthSession, + Lock: state.GetNamedLock, + Scope: atproto.OAuthString, + UpstreamJWK: cli.JWK, + DownstreamJWK: cli.AccessJWK, + ClientMetadata: clientMetadata, + Public: cli.PublicOAuth, + }) + + err = atsync.Migrate(ctx) + if err != nil { + return fmt.Errorf("failed to migrate: %w", err) + } + var replicator replication.Replicator = nil if slices.Contains(cli.Replicators, config.ReplicatorIroh) { exists, err := cli.DataFileExists([]string{"iroh-kv-secret"}) @@ -305,19 +323,6 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu replicator = websocketrep.NewWebsocketReplicator(b, mod, mm) } - op := oatproxy.New(&oatproxy.Config{ - Host: host, - CreateOAuthSession: state.CreateOAuthSession, - UpdateOAuthSession: state.UpdateOAuthSession, - GetOAuthSession: state.LoadOAuthSession, - Lock: state.GetNamedLock, - Scope: atproto.OAuthString, - UpstreamJWK: cli.JWK, - DownstreamJWK: cli.AccessJWK, - ClientMetadata: clientMetadata, - Public: cli.PublicOAuth, - HTTPClient: &aqhttp.Client, - }) d := director.NewDirector(mm, mod, cli, b, op, state, replicator, ldb, atsync) a, err := api.MakeStreamplaceAPI(cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) if err != nil { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 92474d55..ce758d8c 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -11,6 +11,7 @@ var STREAMPLACE_SIGNING_KEY = "signingKey" // var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all +var APP_BSKY_ACTOR_PROFILE = "app.bsky.actor.profile" //nolint:all var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all @@ -68,3 +69,5 @@ const ( WarningC2PASuffering = "cwarn:suffering" WarningC2PAViolence = "cwarn:violence" ) + +const BlueskyProfileGoliveKey = "place.stream.live.golive" diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 37d5faf9..7731ac1d 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -131,14 +131,14 @@ func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotif }) } - ss.Go(ctx, func() error { - err := ss.UpdateProfilePicture(ctx, spseg.Creator) - if err != nil { - log.Error(ctx, "failed to update profile picture", "error", err) - } - <-ctx.Done() - return nil - }) + // ss.Go(ctx, func() error { + // err := ss.UpdateProfilePicture(ctx, spseg.Creator) + // if err != nil { + // log.Error(ctx, "failed to update profile picture", "error", err) + // } + // <-ctx.Done() + // return nil + // }) for { select { diff --git a/pkg/director/stream_session_redcircle.go b/pkg/director/stream_session_redcircle.go index 5b50d653..5bdde645 100644 --- a/pkg/director/stream_session_redcircle.go +++ b/pkg/director/stream_session_redcircle.go @@ -1,120 +1 @@ package director - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "net/http" - - comatproto "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/indigo/api/bsky" - lexutil "github.com/bluesky-social/indigo/lex/util" - - "github.com/bluesky-social/indigo/xrpc" - "stream.place/streamplace/pkg/aqhttp" - "stream.place/streamplace/pkg/log" - "stream.place/streamplace/pkg/redcircle" -) - -func downloadImage(ctx context.Context, url string) ([]byte, error) { - if url == "" { - return nil, errors.New("empty URL provided") - } - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := aqhttp.Do(ctx, req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) - } - - imageData, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read image data: %w", err) - } - - return imageData, nil -} - -func (ss *StreamSession) UpdateProfilePicture(ctx context.Context, repoDID string) error { - session, err := ss.statefulDB.GetSessionByDID(ss.repoDID) - if err != nil { - return fmt.Errorf("failed to get session: %w", err) - } - if session == nil { - return fmt.Errorf("no session found for repoDID: %s", ss.repoDID) - } - session, err = ss.op.RefreshIfNeeded(session) - if err != nil { - return fmt.Errorf("failed to refresh session: %w", err) - } - client, err := ss.op.GetXrpcClient(session) - if err != nil { - return fmt.Errorf("failed to get xrpc client: %w", err) - } - profile, err := ss.atsync.FetchUserProfile(ctx, repoDID) - if err != nil { - return fmt.Errorf("failed to fetch user profile: %w", err) - } - if profile == nil { - return fmt.Errorf("received nil profile from Bluesky API for '%s'", repoDID) - } - if profile.Avatar == nil || *profile.Avatar == "" { - return fmt.Errorf("received nil or empty avatar from Bluesky API for '%s'", repoDID) - } - profileAvatar := *profile.Avatar - imageData, err := downloadImage(ctx, profileAvatar) - if err != nil { - return fmt.Errorf("failed to download image: %w", err) - } - circleImage, err := redcircle.GenerateRedCircle(ctx, imageData) - if err != nil { - return fmt.Errorf("failed to generate redcircle: %w", err) - } - var out comatproto.RepoUploadBlob_Output - err = client.Do(ctx, xrpc.Procedure, "image/jpeg", "com.atproto.repo.uploadBlob", nil, bytes.NewReader(circleImage), &out) - if err != nil { - return fmt.Errorf("failed to upload blob: %w", err) - } - if out.Blob.Size == 0 { - return fmt.Errorf("uploaded blob has size 0") - } - var profileRecord comatproto.RepoGetRecord_Output - err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ - "repo": repoDID, - "collection": "app.bsky.actor.profile", - "rkey": "self", - }, nil, &profileRecord) - if err != nil { - return fmt.Errorf("failed to get profile record: %w", err) - } - actorProfile, ok := profileRecord.Value.Val.(*bsky.ActorProfile) - if !ok { - return fmt.Errorf("profile record is not a actor profile") - } - actorProfile.Avatar = out.Blob - inp := comatproto.RepoPutRecord_Input{ - Collection: "app.bsky.actor.profile", - Record: &lexutil.LexiconTypeDecoder{Val: actorProfile}, - Rkey: "self", - Repo: repoDID, - SwapRecord: profileRecord.Cid, - } - putOutput := comatproto.RepoPutRecord_Output{} - err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &putOutput) - if err != nil { - return fmt.Errorf("failed to put profile record: %w", err) - } - log.Log(ctx, "uploaded redcircle", "ref", out.Blob.Ref.String(), "size", out.Blob.Size) - return nil -} diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go index 6d8fbbf8..ec1dc1df 100644 --- a/pkg/gen/gen.go +++ b/pkg/gen/gen.go @@ -15,6 +15,7 @@ func main() { streamplace.Key{}, streamplace.Livestream{}, streamplace.Livestream_NotificationSettings{}, + streamplace.Livestream_IntegrationSettings{}, streamplace.Segment{}, streamplace.Segment_Audio{}, streamplace.Segment_Video{}, diff --git a/pkg/model/bsky_profile.go b/pkg/model/bsky_profile.go new file mode 100644 index 00000000..833faccb --- /dev/null +++ b/pkg/model/bsky_profile.go @@ -0,0 +1,69 @@ +package model + +import ( + "context" + "errors" + "fmt" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "stream.place/streamplace/pkg/spid" +) + +type BskyProfile struct { + URI string `json:"uri" gorm:"primaryKey;column:uri"` + CID string `json:"cid" gorm:"column:cid"` + RepoDID string `json:"repoDID" gorm:"column:repo_did"` + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` + Record *[]byte `json:"record" gorm:"column:record"` + WasStreamplace bool `json:"wasStreamplace" gorm:"primaryKey;column:was_streamplace"` +} + +func (m *DBModel) UpsertBskyProfile(ctx context.Context, aturi syntax.ATURI, recBs []byte, wasStreamplace bool) error { + cid, err := spid.GetCIDFromBytes(recBs) + if err != nil { + return fmt.Errorf("failed to get cid: %w", err) + } + dbProfile := &BskyProfile{ + URI: aturi.String(), + CID: cid.String(), + RepoDID: aturi.Authority().String(), + Record: &recBs, + WasStreamplace: wasStreamplace, + } + + // Use GORM's OnConflict to handle unique/primary conflicts + // If a conflict (same PK), then update the relevant fields + return m.DB. + Clauses( + // Conflict columns: uri, was_streamplace (matching primaryKey in struct) + clause.OnConflict{ + Columns: []clause.Column{{Name: "uri"}, {Name: "was_streamplace"}}, + DoUpdates: clause.AssignmentColumns([]string{"cid", "repo_did", "record"}), + }, + ). + Create(dbProfile).Error +} + +func (m *DBModel) GetBskyProfile(ctx context.Context, did string, wasStreamplace bool) (*bsky.ActorProfile, error) { + var profile BskyProfile + err := m.DB.Where("uri = ? AND was_streamplace = ?", fmt.Sprintf("at://%s/app.bsky.actor.profile/self", did), wasStreamplace).First(&profile).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + rec, err := lexutil.CborDecodeValue(*profile.Record) + if err != nil { + return nil, fmt.Errorf("failed to decode profile record: %w", err) + } + bskyProfile, ok := rec.(*bsky.ActorProfile) + if !ok { + return nil, fmt.Errorf("invalid profile record") + } + return bskyProfile, nil +} diff --git a/pkg/model/model.go b/pkg/model/model.go index 826a48d0..72bc1439 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -114,6 +114,9 @@ type Model interface { GetRecommendation(userDID string) (*Recommendation, error) UpsertRecommendation(rec *Recommendation) error + + UpsertBskyProfile(ctx context.Context, aturi syntax.ATURI, profileBs []byte, wasStreamplace bool) error + GetBskyProfile(ctx context.Context, did string, wasStreamplace bool) (*bsky.ActorProfile, error) } var DBRevision = 2 @@ -183,6 +186,7 @@ func MakeDB(dbURL string) (Model, error) { Teleport{}, ModerationDelegation{}, Recommendation{}, + BskyProfile{}, } { err = db.AutoMigrate(model) if err != nil { diff --git a/pkg/redcircle/redcircle.go b/pkg/redcircle/redcircle.go deleted file mode 100644 index 07cd5d75..00000000 --- a/pkg/redcircle/redcircle.go +++ /dev/null @@ -1,51 +0,0 @@ -package redcircle - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "image" - _ "image/jpeg" - _ "image/png" - - "github.com/tdewolff/canvas" - "github.com/tdewolff/canvas/renderers" -) - -const WIDTH = 1000.0 -const HEIGHT = 1000.0 - -const BORDER_SIZE = 62.0 - -//go:embed redcircle.png -var RedCirclePNG []byte - -func GenerateRedCircle(ctx context.Context, profileJPEG []byte) ([]byte, error) { - c := canvas.New(WIDTH, HEIGHT) - reader := bytes.NewReader(RedCirclePNG) - img, _, err := image.Decode(reader) - if err != nil { - return nil, fmt.Errorf("failed to decode image: %w", err) - } - - profileReader := bytes.NewReader(profileJPEG) - profileImg, _, err := image.Decode(profileReader) - if err != nil { - return nil, fmt.Errorf("failed to decode image: %w", err) - } - - scaleFactor := WIDTH / (WIDTH - BORDER_SIZE*2) - - // Draw the decoded image onto the canvas - canvasCtx := canvas.NewContext(c) - canvasCtx.DrawImage(BORDER_SIZE, BORDER_SIZE, profileImg, canvas.DPMM(scaleFactor)) - canvasCtx.DrawImage(0, 0, img, canvas.DPMM(1)) - - var buf bytes.Buffer - - if err := c.Write(&buf, renderers.JPEG()); err != nil { - return nil, fmt.Errorf("failed to render to JPEG: %w", err) - } - return buf.Bytes(), nil -} diff --git a/pkg/spid/cid.go b/pkg/spid/cid.go index 22070dbb..d511e601 100644 --- a/pkg/spid/cid.go +++ b/pkg/spid/cid.go @@ -9,13 +9,17 @@ import ( ) func GetCID(record repo.CborMarshaler) (*cid.Cid, error) { - builder := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256) buf := bytes.NewBuffer(nil) err := record.MarshalCBOR(buf) if err != nil { return nil, err } - c, err := builder.Sum(buf.Bytes()) + return GetCIDFromBytes(buf.Bytes()) +} + +func GetCIDFromBytes(bs []byte) (*cid.Cid, error) { + builder := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256) + c, err := builder.Sum(bs) if err != nil { return nil, err } diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index 3bc2fee9..30d7a6bb 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -250,7 +250,7 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 9 + fieldCount := 11 if t.Agent == nil { fieldCount-- @@ -260,6 +260,14 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.IntegrationSettings == nil { + fieldCount-- + } + + if t.LastSeenAt == nil { + fieldCount-- + } + if t.NotificationSettings == nil { fieldCount-- } @@ -447,6 +455,38 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { return err } + // t.LastSeenAt (string) (string) + if t.LastSeenAt != nil { + + if len("lastSeenAt") > 1000000 { + return xerrors.Errorf("Value in field \"lastSeenAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lastSeenAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("lastSeenAt")); err != nil { + return err + } + + if t.LastSeenAt == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.LastSeenAt) > 1000000 { + return xerrors.Errorf("Value in field t.LastSeenAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.LastSeenAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.LastSeenAt)); err != nil { + return err + } + } + } + // t.CanonicalUrl (string) (string) if t.CanonicalUrl != nil { @@ -479,6 +519,25 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } } + // t.IntegrationSettings (streamplace.Livestream_IntegrationSettings) (struct) + if t.IntegrationSettings != nil { + + if len("integrationSettings") > 1000000 { + return xerrors.Errorf("Value in field \"integrationSettings\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("integrationSettings"))); err != nil { + return err + } + if _, err := cw.WriteString(string("integrationSettings")); err != nil { + return err + } + + if err := t.IntegrationSettings.MarshalCBOR(cw); err != nil { + return err + } + } + // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) if t.NotificationSettings != nil { @@ -656,6 +715,27 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { t.CreatedAt = string(sval) } + // t.LastSeenAt (string) (string) + case "lastSeenAt": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LastSeenAt = (*string)(&sval) + } + } // t.CanonicalUrl (string) (string) case "canonicalUrl": @@ -677,6 +757,26 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { t.CanonicalUrl = (*string)(&sval) } } + // t.IntegrationSettings (streamplace.Livestream_IntegrationSettings) (struct) + case "integrationSettings": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.IntegrationSettings = new(Livestream_IntegrationSettings) + if err := t.IntegrationSettings.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.IntegrationSettings pointer: %w", err) + } + } + + } // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) case "notificationSettings": @@ -837,6 +937,135 @@ func (t *Livestream_NotificationSettings) UnmarshalCBOR(r io.Reader) (err error) return nil } +func (t *Livestream_IntegrationSettings) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 1 + + if t.UpdateBskyProfile == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.UpdateBskyProfile (bool) (bool) + if t.UpdateBskyProfile != nil { + + if len("updateBskyProfile") > 1000000 { + return xerrors.Errorf("Value in field \"updateBskyProfile\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updateBskyProfile"))); err != nil { + return err + } + if _, err := cw.WriteString(string("updateBskyProfile")); err != nil { + return err + } + + if t.UpdateBskyProfile == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if err := cbg.WriteBool(w, *t.UpdateBskyProfile); err != nil { + return err + } + } + } + return nil +} + +func (t *Livestream_IntegrationSettings) UnmarshalCBOR(r io.Reader) (err error) { + *t = Livestream_IntegrationSettings{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Livestream_IntegrationSettings: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 17) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.UpdateBskyProfile (bool) (bool) + case "updateBskyProfile": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + if maj != cbg.MajOther { + return fmt.Errorf("booleans must be major type 7") + } + + var val bool + switch extra { + case 20: + val = false + case 21: + val = true + default: + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) + } + t.UpdateBskyProfile = &val + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *Segment) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/pkg/streamplace/streamlivestream.go b/pkg/streamplace/streamlivestream.go index 240f8fcc..50832c11 100644 --- a/pkg/streamplace/streamlivestream.go +++ b/pkg/streamplace/streamlivestream.go @@ -24,7 +24,10 @@ type Livestream struct { // canonicalUrl: The primary URL where this livestream can be viewed, if available. CanonicalUrl *string `json:"canonicalUrl,omitempty" cborgen:"canonicalUrl,omitempty"` // createdAt: Client-declared timestamp when this livestream started. - CreatedAt string `json:"createdAt" cborgen:"createdAt"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + IntegrationSettings *Livestream_IntegrationSettings `json:"integrationSettings,omitempty" cborgen:"integrationSettings,omitempty"` + // lastSeenAt: Client-declared timestamp when this livestream was last seen by the user. + LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"` NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"` // post: The post that announced this livestream. Post *comatproto.RepoStrongRef `json:"post,omitempty" cborgen:"post,omitempty"` @@ -35,6 +38,12 @@ type Livestream struct { Url *string `json:"url,omitempty" cborgen:"url,omitempty"` } +// Livestream_IntegrationSettings is a "integrationSettings" in the place.stream.livestream schema. +type Livestream_IntegrationSettings struct { + // updateBskyProfile: Whether this livestream should update the Bluesky profile picture & pinned post. + UpdateBskyProfile *bool `json:"updateBskyProfile,omitempty" cborgen:"updateBskyProfile,omitempty"` +} + // Livestream_LivestreamView is a "livestreamView" in the place.stream.livestream schema. type Livestream_LivestreamView struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#livestreamView"` -- 2.51.2 From 07c127f91275bedc2aa73b9e86f357e69162b0fb Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 21:12:58 -0800 Subject: [PATCH 10/49] redcircle: fix scaling math when not 1000x1000 --- pkg/atproto/.gitignore | 2 ++ pkg/atproto/redcircle.go | 11 ++++++++++- pkg/atproto/redcircle_test.go | 33 +++++++++++++++++++++++---------- pkg/atproto/scumbag.png | Bin 0 -> 78612 bytes pkg/redcircle/.gitignore | 1 - 5 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 pkg/atproto/.gitignore create mode 100644 pkg/atproto/scumbag.png delete mode 100644 pkg/redcircle/.gitignore diff --git a/pkg/atproto/.gitignore b/pkg/atproto/.gitignore new file mode 100644 index 00000000..c69c88c1 --- /dev/null +++ b/pkg/atproto/.gitignore @@ -0,0 +1,2 @@ +test.jpg +test_scumbag.jpg diff --git a/pkg/atproto/redcircle.go b/pkg/atproto/redcircle.go index a05a50a5..866e0f8e 100644 --- a/pkg/atproto/redcircle.go +++ b/pkg/atproto/redcircle.go @@ -28,6 +28,8 @@ const HEIGHT = 1000.0 const BORDER_SIZE = 62.0 +const BorderScaleFactor = WIDTH / (WIDTH - BORDER_SIZE*2) + //go:embed redcircle.png var RedCirclePNG []byte @@ -45,7 +47,14 @@ func GenerateRedCircle(ctx context.Context, profileJPEG []byte) ([]byte, error) return nil, fmt.Errorf("failed to decode image: %w", err) } - scaleFactor := WIDTH / (WIDTH - BORDER_SIZE*2) + // profileWidth := profileImg.Bounds().Max.X + + scaleFactor := (float64(profileImg.Bounds().Max.X) / float64(WIDTH)) * BorderScaleFactor + // scaleFactor := 1.0 + + fmt.Println("scaleFactor", scaleFactor) + + // // Draw the decoded image onto the canvas canvasCtx := canvas.NewContext(c) diff --git a/pkg/atproto/redcircle_test.go b/pkg/atproto/redcircle_test.go index f4cd1fc6..5775b796 100644 --- a/pkg/atproto/redcircle_test.go +++ b/pkg/atproto/redcircle_test.go @@ -9,14 +9,27 @@ import ( ) func TestGenerateRedCircle(t *testing.T) { - bs, err := os.ReadFile("robot.jpg") - require.NoError(t, err) - img, err := GenerateRedCircle(context.Background(), bs) - require.NoError(t, err) - require.NotNil(t, img) - f, err := os.Create("test.jpg") - require.NoError(t, err) - defer f.Close() - _, err = f.Write(img) - require.NoError(t, err) + testCases := []struct { + infile string + outfile string + }{ + {"robot.jpg", "test.jpg"}, + {"scumbag.png", "test_scumbag.jpg"}, + } + + for _, tc := range testCases { + tc := tc // capture for parallel tests + t.Run(tc.infile, func(t *testing.T) { + bs, err := os.ReadFile(tc.infile) + require.NoError(t, err) + img, err := GenerateRedCircle(context.Background(), bs) + require.NoError(t, err) + require.NotNil(t, img) + f, err := os.Create(tc.outfile) + require.NoError(t, err) + defer f.Close() + _, err = f.Write(img) + require.NoError(t, err) + }) + } } diff --git a/pkg/atproto/scumbag.png b/pkg/atproto/scumbag.png new file mode 100644 index 0000000000000000000000000000000000000000..ee020e980c602c7ae9baaf74b5a2df814284a321 GIT binary patch literal 78612 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°LpuT6xYv@0qt13ta8ujbrsb}knY7GG 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 VR$!dlmLH%P@pScbS?83{1OP|p9%TRk literal 0 HcmV?d00001 diff --git a/pkg/redcircle/.gitignore b/pkg/redcircle/.gitignore deleted file mode 100644 index a25287a1..00000000 --- a/pkg/redcircle/.gitignore +++ /dev/null @@ -1 +0,0 @@ -test.jpg -- 2.51.2 From c8b9ce07ae3f08fb60745ecde8dea3faf147e8ad Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 21:48:35 -0800 Subject: [PATCH 11/49] redcircle: it's in the queue now --- pkg/atproto/sync.go | 35 ++----------- pkg/cmd/streamplace.go | 1 + pkg/{atproto => redcircle}/redcircle.go | 33 +++--------- pkg/{atproto => redcircle}/redcircle.png | Bin pkg/{atproto => redcircle}/redcircle_test.go | 2 +- pkg/{atproto => redcircle}/robot.jpg | Bin pkg/{atproto => redcircle}/scumbag.png | Bin pkg/{atproto => redcircle}/test.jpg | Bin pkg/redcircle/test_scumbag.jpg | Bin 0 -> 40166 bytes pkg/statedb/queue_processor.go | 51 +++++++++++++++++++ pkg/statedb/statedb.go | 2 +- 11 files changed, 65 insertions(+), 59 deletions(-) rename pkg/{atproto => redcircle}/redcircle.go (85%) rename pkg/{atproto => redcircle}/redcircle.png (100%) rename pkg/{atproto => redcircle}/redcircle_test.go (97%) rename pkg/{atproto => redcircle}/robot.jpg (100%) rename pkg/{atproto => redcircle}/scumbag.png (100%) rename pkg/{atproto => redcircle}/test.jpg (100%) create mode 100644 pkg/redcircle/test_scumbag.jpg diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 11477434..3cb5d3a0 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -366,39 +366,14 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } go atsync.Bus.Publish(userDID, lsv) - var postView *bsky.FeedDefs_PostView - if lsHydrated.Post != nil { - postView, err = lsHydrated.Post.ToBskyPostView() - if err != nil { - return fmt.Errorf("failed to convert livestream post to bsky post view: %w", err) - } - } - if !isUpdate && !isFirstSync { - task := &statedb.NotificationTask{ - Livestream: lsv, - FeedPost: postView, - PDSURL: r.PDS, - } - - cp, err := atsync.Model.GetChatProfile(ctx, userDID) - if err != nil { - return fmt.Errorf("failed to get chat profile: %w", err) - } - if cp != nil { - spcp, err := cp.ToStreamplaceChatProfile() - if err != nil { - return fmt.Errorf("failed to convert chat profile to streamplace chat profile: %w", err) - } - task.ChatProfile = spcp - } - if rec.IntegrationSettings != nil && rec.IntegrationSettings.UpdateBskyProfile != nil && *rec.IntegrationSettings.UpdateBskyProfile { - // TODO DONTMERGE: move this to a background job - log.Warn(ctx, "updating bluesky profile picture", "userDID", userDID) - err = atsync.UpdateProfilePicture(ctx, userDID) + task := &statedb.AddRedCircleTask{ + UserDID: userDID, + } + _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskAddRedCircle, task, statedb.WithTaskKey(fmt.Sprintf("add-red-circle::%s", aturi.String()))) if err != nil { - return fmt.Errorf("failed to update profile picture: %w", err) + return fmt.Errorf("failed to enqueue add red circle task: %w", err) } } } diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index a72a9a18..4a399290 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -278,6 +278,7 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu ClientMetadata: clientMetadata, Public: cli.PublicOAuth, }) + state.OATProxy = op err = atsync.Migrate(ctx) if err != nil { diff --git a/pkg/atproto/redcircle.go b/pkg/redcircle/redcircle.go similarity index 85% rename from pkg/atproto/redcircle.go rename to pkg/redcircle/redcircle.go index 866e0f8e..5abe42a0 100644 --- a/pkg/atproto/redcircle.go +++ b/pkg/redcircle/redcircle.go @@ -1,4 +1,4 @@ -package atproto +package redcircle import ( "bytes" @@ -14,6 +14,7 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atdata" + "github.com/streamplace/oatproxy/pkg/oatproxy" "github.com/bluesky-social/indigo/xrpc" "github.com/tdewolff/canvas" @@ -21,6 +22,7 @@ import ( "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/model" ) const WIDTH = 1000.0 @@ -47,14 +49,7 @@ func GenerateRedCircle(ctx context.Context, profileJPEG []byte) ([]byte, error) return nil, fmt.Errorf("failed to decode image: %w", err) } - // profileWidth := profileImg.Bounds().Max.X - scaleFactor := (float64(profileImg.Bounds().Max.X) / float64(WIDTH)) * BorderScaleFactor - // scaleFactor := 1.0 - - fmt.Println("scaleFactor", scaleFactor) - - // // Draw the decoded image onto the canvas canvasCtx := canvas.NewContext(c) @@ -97,24 +92,8 @@ func downloadImage(ctx context.Context, url string) ([]byte, error) { return imageData, nil } -func (atsync *ATProtoSynchronizer) UpdateProfilePicture(ctx context.Context, repoDID string) error { - session, err := atsync.StatefulDB.GetSessionByDID(repoDID) - if err != nil { - return fmt.Errorf("failed to get session: %w", err) - } - if session == nil { - return fmt.Errorf("no session found for repoDID: %s", repoDID) - } - session, err = atsync.OATProxy.RefreshIfNeeded(session) - if err != nil { - return fmt.Errorf("failed to refresh session: %w", err) - } - client, err := atsync.OATProxy.GetXrpcClient(session) - if err != nil { - return fmt.Errorf("failed to get xrpc client: %w", err) - } - - oldProfile, err := atsync.Model.GetBskyProfile(ctx, repoDID, false) +func UpdateProfilePicture(ctx context.Context, repoDID string, client *oatproxy.XrpcClient, mod model.Model) error { + oldProfile, err := mod.GetBskyProfile(ctx, repoDID, false) if err != nil { return fmt.Errorf("failed to get old profile: %w", err) } @@ -125,7 +104,7 @@ func (atsync *ATProtoSynchronizer) UpdateProfilePicture(ctx context.Context, rep return fmt.Errorf("no avatar found for old profile") } - repo, err := atsync.Model.GetRepo(repoDID) + repo, err := mod.GetRepo(repoDID) if err != nil { return fmt.Errorf("failed to get repo: %w", err) } diff --git a/pkg/atproto/redcircle.png b/pkg/redcircle/redcircle.png similarity index 100% rename from pkg/atproto/redcircle.png rename to pkg/redcircle/redcircle.png diff --git a/pkg/atproto/redcircle_test.go b/pkg/redcircle/redcircle_test.go similarity index 97% rename from pkg/atproto/redcircle_test.go rename to pkg/redcircle/redcircle_test.go index 5775b796..e97b331e 100644 --- a/pkg/atproto/redcircle_test.go +++ b/pkg/redcircle/redcircle_test.go @@ -1,4 +1,4 @@ -package atproto +package redcircle import ( "context" diff --git a/pkg/atproto/robot.jpg b/pkg/redcircle/robot.jpg similarity index 100% rename from pkg/atproto/robot.jpg rename to pkg/redcircle/robot.jpg diff --git a/pkg/atproto/scumbag.png b/pkg/redcircle/scumbag.png similarity index 100% rename from pkg/atproto/scumbag.png rename to pkg/redcircle/scumbag.png diff --git a/pkg/atproto/test.jpg b/pkg/redcircle/test.jpg similarity index 100% rename from pkg/atproto/test.jpg rename to pkg/redcircle/test.jpg diff --git a/pkg/redcircle/test_scumbag.jpg b/pkg/redcircle/test_scumbag.jpg new file mode 100644 index 0000000000000000000000000000000000000000..83a56ccd8579bd336b5b3965de7b70a9d649e161 GIT binary patch literal 40166 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

NQ#wW6T)0=>kJw-b|mSW3d3dKiNY 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 literal 0 HcmV?d00001 diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 139c8927..2cfcc677 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -12,11 +12,14 @@ import ( "stream.place/streamplace/pkg/integrations/webhook" "stream.place/streamplace/pkg/log" notificationpkg "stream.place/streamplace/pkg/notifications" + "stream.place/streamplace/pkg/redcircle" "stream.place/streamplace/pkg/streamplace" ) var TaskNotification = "notification" var TaskChat = "chat" +var TaskAddRedCircle = "add_red_circle" +var TaskRemoveRedCircle = "remove_red_circle" type NotificationTask struct { Livestream *streamplace.Livestream_LivestreamView @@ -29,6 +32,14 @@ type ChatTask struct { MessageView *streamplace.ChatDefs_MessageView } +type AddRedCircleTask struct { + UserDID string +} + +type RemoveRedCircleTask struct { + UserDID string +} + func (state *StatefulDB) ProcessQueue(ctx context.Context) error { for { task, err := state.DequeueTask(ctx, "queue_processor") @@ -60,11 +71,51 @@ func (state *StatefulDB) processTask(ctx context.Context, task *AppTask) error { return state.processNotificationTask(ctx, task) case TaskChat: return state.processChatMessageTask(ctx, task) + case TaskAddRedCircle: + return state.processAddRedCircleTask(ctx, task) + case TaskRemoveRedCircle: + return state.processRemoveRedCircleTask(ctx, task) default: return fmt.Errorf("unknown task type: %s", task.Type) } } +func (state *StatefulDB) processAddRedCircleTask(ctx context.Context, task *AppTask) error { + var addRedCircleTask AddRedCircleTask + if err := json.Unmarshal(task.Payload, &addRedCircleTask); err != nil { + return err + } + repoDID := addRedCircleTask.UserDID + session, err := state.GetSessionByDID(repoDID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) + } + if session == nil { + return fmt.Errorf("no session found for repoDID: %s", repoDID) + } + session, err = state.OATProxy.RefreshIfNeeded(session) + if err != nil { + return fmt.Errorf("failed to refresh session: %w", err) + } + client, err := state.OATProxy.GetXrpcClient(session) + if err != nil { + return fmt.Errorf("failed to get xrpc client: %w", err) + } + err = redcircle.UpdateProfilePicture(ctx, repoDID, client, state.model) + if err != nil { + return fmt.Errorf("failed to update profile picture: %w", err) + } + return nil +} + +func (state *StatefulDB) processRemoveRedCircleTask(ctx context.Context, task *AppTask) error { + var removeRedCircleTask RemoveRedCircleTask + if err := json.Unmarshal(task.Payload, &removeRedCircleTask); err != nil { + return err + } + return nil +} + func (state *StatefulDB) processNotificationTask(ctx context.Context, task *AppTask) error { var notificationTask NotificationTask if err := json.Unmarshal(task.Payload, ¬ificationTask); err != nil { diff --git a/pkg/statedb/statedb.go b/pkg/statedb/statedb.go index ba2bbea2..c1e06e1f 100644 --- a/pkg/statedb/statedb.go +++ b/pkg/statedb/statedb.go @@ -37,7 +37,7 @@ type StatefulDB struct { // pgLockConn is used to hold a connection to the database for locking pgLockConn *gorm.DB pgLockConnMu sync.Mutex - op *oatproxy.OATProxy + OATProxy *oatproxy.OATProxy } // list tables here so we can migrate them -- 2.51.2 From 9f7e6015b3d3f67d743495b9d23723d85c682971 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 22:36:19 -0800 Subject: [PATCH 12/49] redcircle: delayed queue is working, yay --- .../src/streamplace-store/stream.tsx | 2 + pkg/atproto/sync.go | 35 ++++++-- pkg/director/stream_session.go | 81 +++++++++++++++++++ pkg/director/stream_session_redcircle.go | 1 - pkg/statedb/queue_processor.go | 28 +++++++ 5 files changed, 139 insertions(+), 8 deletions(-) delete mode 100644 pkg/director/stream_session_redcircle.go diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index 9c387c71..e20481c8 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -257,6 +257,7 @@ export function useCreateStreamRecord() { title: title, url: thisUrl, createdAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), // would match up with e.g. https://stream.place/iame.li canonicalUrl: canonicalUrl, // user agent style string @@ -265,6 +266,7 @@ export function useCreateStreamRecord() { post: newPost, thumb: thumbnail, }; + console.log("record", record); if (notificationSettings) { record.notificationSettings = notificationSettings; diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 3cb5d3a0..d312dc82 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -12,6 +12,7 @@ import ( "github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/atproto/atdata" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/util" "stream.place/streamplace/pkg/aqtime" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" @@ -366,14 +367,34 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } go atsync.Bus.Publish(userDID, lsv) - if !isUpdate && !isFirstSync { - if rec.IntegrationSettings != nil && rec.IntegrationSettings.UpdateBskyProfile != nil && *rec.IntegrationSettings.UpdateBskyProfile { - task := &statedb.AddRedCircleTask{ - UserDID: userDID, + if !isFirstSync { + if !isUpdate { + if rec.IntegrationSettings != nil && rec.IntegrationSettings.UpdateBskyProfile != nil && *rec.IntegrationSettings.UpdateBskyProfile { + task := &statedb.AddRedCircleTask{ + UserDID: userDID, + } + _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskAddRedCircle, task, statedb.WithTaskKey(fmt.Sprintf("add-red-circle::%s", aturi.String()))) + if err != nil { + return fmt.Errorf("failed to enqueue add red circle task: %w", err) + } } - _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskAddRedCircle, task, statedb.WithTaskKey(fmt.Sprintf("add-red-circle::%s", aturi.String()))) - if err != nil { - return fmt.Errorf("failed to enqueue add red circle task: %w", err) + } + // queue a task to clean up the livestream if it's been inactive for too long + task := &statedb.RemoveRedCircleTask{ + UserDID: userDID, + } + if rec.LastSeenAt != nil { + scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt) + if err == nil { + scheduledAt = scheduledAt.Add(90 * time.Second).UTC() + taskKey := fmt.Sprintf("remove-red-circle::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601)) + log.Warn(ctx, "queueing remove red circle task", "taskKey", taskKey, "scheduledAt", scheduledAt) + _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskRemoveRedCircle, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt)) + if err != nil { + return fmt.Errorf("failed to enqueue remove red circle task: %w", err) + } + } else { + log.Error(ctx, "failed to parse last seen at", "err", err) } } } diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 7731ac1d..431274da 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -5,10 +5,12 @@ import ( "context" "fmt" "net/url" + "sync" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/util" "github.com/bluesky-social/indigo/xrpc" @@ -59,6 +61,9 @@ type StreamSession struct { statefulDB *statedb.StatefulDB replicator replication.Replicator atsync *atproto.ATProtoSynchronizer + + lastLivestreamLock sync.Mutex + lastLivestreamTime time.Time } func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotification) error { @@ -217,6 +222,9 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment if notif.Local { ss.UpdateStatus(ctx, spseg.Creator) ss.UpdateBroadcastOrigin(ctx) + ss.Go(ctx, func() error { + return ss.UpdateLivestream(ctx, spseg.Creator) + }) } if ss.cli.LivepeerGatewayURL != "" { @@ -465,6 +473,79 @@ func (ss *StreamSession) doUpdateStatus(ctx context.Context, repoDID string) err return nil } +var livestreamUpdateInterval = time.Second * 30 + +func (ss *StreamSession) UpdateLivestream(ctx context.Context, repoDID string) error { + ctx = log.WithLogValues(ctx, "func", "UpdateLivestream") + ss.lastLivestreamLock.Lock() + defer ss.lastLivestreamLock.Unlock() + if time.Since(ss.lastLivestreamTime) < livestreamUpdateInterval { + log.Debug(ctx, "not updating livestream, last livestream was less than 30 seconds ago") + return nil + } + + lastLivestream, err := ss.mod.GetLatestLivestreamForRepo(repoDID) + if err != nil { + return fmt.Errorf("could not get latest livestream for repoDID: %w", err) + } + if lastLivestream == nil { + log.Debug(ctx, "no livestream found, skipping livestream update") + return nil + } + lsv, err := lastLivestream.ToLivestreamView() + if err != nil { + return fmt.Errorf("could not convert livestream to streamplace livestream: %w", err) + } + lsvr, ok := lsv.Record.Val.(*streamplace.Livestream) + if !ok { + return fmt.Errorf("livestream is not a streamplace livestream") + } + if lsvr.LastSeenAt == nil { + log.Debug(ctx, "livestream has no last seen at, skipping update") + return nil + } + lastSeenTime, err := time.Parse(time.RFC3339, *lsvr.LastSeenAt) + if err != nil { + return fmt.Errorf("could not parse last seen at: %w", err) + } + if time.Since(lastSeenTime) > 5*time.Minute { + log.Debug(ctx, "livestream is inactive, skipping update", "lastSeenAt", lastSeenTime) + return nil + } + + aturi, err := syntax.ParseATURI(lastLivestream.URI) + if err != nil { + return fmt.Errorf("could not parse livestream URI: %w", err) + } + + now := time.Now().UTC().Format(util.ISO8601) + lsvr.LastSeenAt = &now + + inp := comatproto.RepoPutRecord_Input{ + Collection: "place.stream.livestream", + Record: &lexutil.LexiconTypeDecoder{Val: lsvr}, + Rkey: aturi.RecordKey().String(), + Repo: ss.repoDID, + SwapRecord: &lastLivestream.CID, + } + out := comatproto.RepoPutRecord_Output{} + + client, err := ss.GetClientByDID(ss.repoDID) + if err != nil { + return fmt.Errorf("could not get xrpc client for repoDID: %w", err) + } + + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &out) + if err != nil { + return fmt.Errorf("could not update livestream record: %w", err) + } + + log.Warn(ctx, "updated livestream record", "uri", lastLivestream.URI) + ss.lastLivestreamTime = time.Now() + + return nil +} + func (ss *StreamSession) DeleteStatus(repoDID string) error { // need a special extra context because the stream session context is already cancelled // No lock needed - this runs during teardown after the background worker has exited diff --git a/pkg/director/stream_session_redcircle.go b/pkg/director/stream_session_redcircle.go deleted file mode 100644 index 5bdde645..00000000 --- a/pkg/director/stream_session_redcircle.go +++ /dev/null @@ -1 +0,0 @@ -package director diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 2cfcc677..3fb14a3f 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -113,6 +113,34 @@ func (state *StatefulDB) processRemoveRedCircleTask(ctx context.Context, task *A if err := json.Unmarshal(task.Payload, &removeRedCircleTask); err != nil { return err } + userDID := removeRedCircleTask.UserDID + lastLivestream, err := state.model.GetLatestLivestreamForRepo(userDID) + if err != nil { + return fmt.Errorf("failed to get latest livestream for userDID: %w", err) + } + if lastLivestream == nil { + return fmt.Errorf("no livestream found for userDID: %s", userDID) + } + lastLivestreamView, err := lastLivestream.ToLivestreamView() + if err != nil { + return fmt.Errorf("failed to convert livestream to streamplace livestream: %w", err) + } + rec, ok := lastLivestreamView.Record.Val.(*streamplace.Livestream) + if !ok { + return fmt.Errorf("livestream is not a streamplace livestream") + } + if rec.LastSeenAt == nil { + return fmt.Errorf("livestream has no last seen at") + } + lastSeenTime, err := time.Parse(time.RFC3339, *rec.LastSeenAt) + if err != nil { + return fmt.Errorf("could not parse last seen at: %w", err) + } + if time.Since(lastSeenTime) < 60*time.Second { + log.Warn(ctx, "livestream is active, skipping removal of red circle", "lastSeenAt", lastSeenTime) + return nil + } + log.Warn(ctx, "removing red circle", "userDID", userDID, "lastSeenAt", lastSeenTime) return nil } -- 2.51.2 From e32a006079de36ed5a73c9b2ca40acdb72087dbd Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 15 Jan 2026 22:54:27 -0800 Subject: [PATCH 13/49] redcircle: working! but with sloppy stashing, we can do better --- pkg/redcircle/redcircle.go | 36 ++++++++++++++++++++++++++++++++++ pkg/statedb/queue_processor.go | 17 +++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/pkg/redcircle/redcircle.go b/pkg/redcircle/redcircle.go index 5abe42a0..7733f430 100644 --- a/pkg/redcircle/redcircle.go +++ b/pkg/redcircle/redcircle.go @@ -14,6 +14,7 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atdata" + lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/streamplace/oatproxy/pkg/oatproxy" "github.com/bluesky-social/indigo/xrpc" @@ -140,6 +141,7 @@ func UpdateProfilePicture(ctx context.Context, repoDID string, client *oatproxy. if err != nil { return fmt.Errorf("failed to get profile record: %w", err) } + oldAvatar := oldProfile.Avatar oldProfile.Avatar = out.Blob buf := bytes.Buffer{} err = oldProfile.MarshalCBOR(&buf) @@ -151,6 +153,7 @@ func UpdateProfilePicture(ctx context.Context, repoDID string, client *oatproxy. return fmt.Errorf("failed to unmarhsal record CBOR: %w", err) } kv[constants.BlueskyProfileGoliveKey] = true + kv["place.stream.stashedAvatar"] = oldAvatar inp := map[string]any{ "collection": "app.bsky.actor.profile", "record": kv, @@ -166,3 +169,36 @@ func UpdateProfilePicture(ctx context.Context, repoDID string, client *oatproxy. log.Log(ctx, "uploaded redcircle", "ref", out.Blob.Ref.String(), "size", out.Blob.Size) return nil } + +func RestoreProfilePicture(ctx context.Context, repoDID string, client *oatproxy.XrpcClient, mod model.Model) error { + oldProfile, err := mod.GetBskyProfile(ctx, repoDID, false) + if err != nil { + return fmt.Errorf("failed to get old profile: %w", err) + } + if oldProfile == nil { + return fmt.Errorf("no old profile found for repoDID: %s", repoDID) + } + var getRecordOut comatproto.RepoGetRecord_Output + err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ + "repo": repoDID, + "collection": "app.bsky.actor.profile", + "rkey": "self", + }, nil, &getRecordOut) + if err != nil { + return fmt.Errorf("failed to get profile record: %w", err) + } + inp := comatproto.RepoPutRecord_Input{ + Collection: "app.bsky.actor.profile", + Record: &lexutil.LexiconTypeDecoder{Val: oldProfile}, + Rkey: "self", + Repo: repoDID, + SwapRecord: getRecordOut.Cid, + } + putOutput := comatproto.RepoPutRecord_Output{} + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &putOutput) + if err != nil { + return fmt.Errorf("failed to put profile record: %w", err) + } + log.Log(ctx, "restored profile picture", "cid", putOutput.Cid) + return nil +} diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 3fb14a3f..5b5618e0 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -140,7 +140,22 @@ func (state *StatefulDB) processRemoveRedCircleTask(ctx context.Context, task *A log.Warn(ctx, "livestream is active, skipping removal of red circle", "lastSeenAt", lastSeenTime) return nil } - log.Warn(ctx, "removing red circle", "userDID", userDID, "lastSeenAt", lastSeenTime) + session, err := state.GetSessionByDID(userDID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) + } + session, err = state.OATProxy.RefreshIfNeeded(session) + if err != nil { + return fmt.Errorf("failed to refresh session: %w", err) + } + client, err := state.OATProxy.GetXrpcClient(session) + if err != nil { + return fmt.Errorf("failed to get xrpc client: %w", err) + } + err = redcircle.RestoreProfilePicture(ctx, userDID, client, state.model) + if err != nil { + return fmt.Errorf("failed to restore profile picture: %w", err) + } return nil } -- 2.51.2 From ac14b00d0c105943f8e812f836f3c24331de0abf Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 17 Feb 2026 14:02:41 -0800 Subject: [PATCH 14/49] prelive: remove all redcircle logic, rename to finalize-livestream --- .../live-dashboard/livestream-panel.tsx | 24 --- .../src/streamplace-store/stream.tsx | 6 - .../lex-reference/place-stream-livestream.md | 29 --- lexicons/place/stream/livestream.json | 14 -- pkg/atproto/sync.go | 21 +- pkg/constants/constants.go | 5 + pkg/gen/gen.go | 1 - pkg/media/manifest_builder.go | 2 +- pkg/model/livestream.go | 16 ++ pkg/model/model.go | 1 + pkg/redcircle/redcircle.go | 204 ------------------ pkg/redcircle/redcircle.png | Bin 26138 -> 0 bytes pkg/redcircle/redcircle_test.go | 35 --- pkg/redcircle/robot.jpg | Bin 18830 -> 0 bytes pkg/redcircle/scumbag.png | Bin 78612 -> 0 bytes pkg/redcircle/test.jpg | Bin 73910 -> 0 bytes pkg/redcircle/test_scumbag.jpg | Bin 40166 -> 0 bytes pkg/statedb/queue_processor.go | 73 ++----- pkg/streamplace/cbor_gen.go | 174 +-------------- pkg/streamplace/streamlivestream.go | 9 +- 20 files changed, 47 insertions(+), 567 deletions(-) delete mode 100644 pkg/redcircle/redcircle.go delete mode 100644 pkg/redcircle/redcircle.png delete mode 100644 pkg/redcircle/redcircle_test.go delete mode 100644 pkg/redcircle/robot.jpg delete mode 100644 pkg/redcircle/scumbag.png delete mode 100644 pkg/redcircle/test.jpg delete mode 100644 pkg/redcircle/test_scumbag.jpg diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index aaa9e6c3..f0c38e21 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -225,7 +225,6 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { const [createPost, setCreatePost] = useState(true); const [sendPushNotification, setSendPushNotification] = useState(true); const [canonicalUrl, setCanonicalUrl] = useState(""); - const [updateBskyProfile, setUpdateBskyProfile] = useState(true); const defaultCanonicalUrl = useMemo(() => { return `${url}/${profile && formatHandle(profile)}`; }, [url, profile?.handle]); @@ -258,14 +257,6 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { ); } - if ( - typeof livestream.record.integrationSettings?.updateBskyProfile === - "boolean" - ) { - setUpdateBskyProfile( - livestream.record.integrationSettings.updateBskyProfile, - ); - } setCreatePost(typeof livestream.record.post !== "undefined"); }, [livestream, defaultCanonicalUrl]); @@ -304,9 +295,6 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { notificationSettings: { pushNotification: sendPushNotification, }, - integrationSettings: { - updateBskyProfile: updateBskyProfile, - }, canonicalUrl: canonicalUrl || undefined, }); } else { @@ -640,18 +628,6 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { style={[{ fontSize: 12 }]} /> - - - setUpdateBskyProfile(checked)} - label={"Update Bluesky avatar"} - style={[{ fontSize: 12 }]} - /> - {/* Image upload for create mode */} diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index e20481c8..830fb6ce 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -134,14 +134,12 @@ export function useCreateStreamRecord() { submitPost, canonicalUrl, notificationSettings, - integrationSettings, }: { title: string; customThumbnail?: Blob; submitPost?: boolean; canonicalUrl?: string; notificationSettings?: PlaceStreamLivestream.NotificationSettings; - integrationSettings?: PlaceStreamLivestream.IntegrationSettings; }) => { if (typeof submitPost !== "boolean") { submitPost = true; @@ -272,10 +270,6 @@ export function useCreateStreamRecord() { record.notificationSettings = notificationSettings; } - if (integrationSettings) { - record.integrationSettings = integrationSettings; - } - await agent.com.atproto.repo.createRecord({ repo: agent.did, collection: "place.stream.livestream", diff --git a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md index 2c28c6f7..3da8804c 100644 --- a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md +++ b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md @@ -30,7 +30,6 @@ Record announcing a livestream is happening | `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | | `thumb` | `blob` | ❌ | | Accept: `image/*`
Max Size: 1000000 bytes | | `notificationSettings` | [`place.stream.livestream#notificationSettings`](/lex-reference/place-stream-livestream#notificationsettings) | ❌ | | | -| `integrationSettings` | [`place.stream.livestream#integrationSettings`](/lex-reference/place-stream-livestream#integrationsettings) | ❌ | | | --- @@ -48,20 +47,6 @@ Record announcing a livestream is happening --- -
- -### `integrationSettings` - -**Type:** `object` - -**Properties:** - -| Name | Type | Req'd | Description | Constraints | -| ------------------- | --------- | ----- | -------------------------------------------------------------------------------- | ----------- | -| `updateBskyProfile` | `boolean` | ❌ | Whether this livestream should update the Bluesky profile picture & pinned post. | | - ---- - ### `livestreamView` @@ -200,10 +185,6 @@ Record announcing a livestream is happening "notificationSettings": { "type": "ref", "ref": "place.stream.livestream#notificationSettings" - }, - "integrationSettings": { - "type": "ref", - "ref": "place.stream.livestream#integrationSettings" } } } @@ -218,16 +199,6 @@ Record announcing a livestream is happening } } }, - "integrationSettings": { - "type": "object", - "required": [], - "properties": { - "updateBskyProfile": { - "type": "boolean", - "description": "Whether this livestream should update the Bluesky profile picture & pinned post." - } - } - }, "livestreamView": { "type": "object", "required": ["uri", "cid", "author", "record", "indexedAt"], diff --git a/lexicons/place/stream/livestream.json b/lexicons/place/stream/livestream.json index 8814416c..561f56c1 100644 --- a/lexicons/place/stream/livestream.json +++ b/lexicons/place/stream/livestream.json @@ -53,10 +53,6 @@ "notificationSettings": { "type": "ref", "ref": "place.stream.livestream#notificationSettings" - }, - "integrationSettings": { - "type": "ref", - "ref": "place.stream.livestream#integrationSettings" } } } @@ -71,16 +67,6 @@ } } }, - "integrationSettings": { - "type": "object", - "required": [], - "properties": { - "updateBskyProfile": { - "type": "boolean", - "description": "Whether this livestream should update the Bluesky profile picture & pinned post." - } - } - }, "livestreamView": { "type": "object", "required": ["uri", "cid", "author", "record", "indexedAt"], diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index d312dc82..465f7988 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -368,28 +368,17 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD go atsync.Bus.Publish(userDID, lsv) if !isFirstSync { - if !isUpdate { - if rec.IntegrationSettings != nil && rec.IntegrationSettings.UpdateBskyProfile != nil && *rec.IntegrationSettings.UpdateBskyProfile { - task := &statedb.AddRedCircleTask{ - UserDID: userDID, - } - _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskAddRedCircle, task, statedb.WithTaskKey(fmt.Sprintf("add-red-circle::%s", aturi.String()))) - if err != nil { - return fmt.Errorf("failed to enqueue add red circle task: %w", err) - } - } - } // queue a task to clean up the livestream if it's been inactive for too long - task := &statedb.RemoveRedCircleTask{ - UserDID: userDID, + task := &statedb.FinalizeLivestreamTask{ + LivestreamURI: aturi.String(), } if rec.LastSeenAt != nil { scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt) if err == nil { - scheduledAt = scheduledAt.Add(90 * time.Second).UTC() - taskKey := fmt.Sprintf("remove-red-circle::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601)) + scheduledAt = scheduledAt.Add(constants.LivestreamInactiveCheckInterval).UTC() + taskKey := fmt.Sprintf("finalize-livestream::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601)) log.Warn(ctx, "queueing remove red circle task", "taskKey", taskKey, "scheduledAt", scheduledAt) - _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskRemoveRedCircle, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt)) + _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestream, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt)) if err != nil { return fmt.Errorf("failed to enqueue remove red circle task: %w", err) } diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index ce758d8c..a8c70337 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1,5 +1,7 @@ package constants +import "time" + var PLACE_STREAM_KEY = "place.stream.key" //nolint:all var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all @@ -71,3 +73,6 @@ const ( ) const BlueskyProfileGoliveKey = "place.stream.live.golive" + +const LivestreamInactiveCheckInterval = 90 * time.Second +const LivestreamConsideredInactiveAfter = 60 * time.Second diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go index ec1dc1df..6d8fbbf8 100644 --- a/pkg/gen/gen.go +++ b/pkg/gen/gen.go @@ -15,7 +15,6 @@ func main() { streamplace.Key{}, streamplace.Livestream{}, streamplace.Livestream_NotificationSettings{}, - streamplace.Livestream_IntegrationSettings{}, streamplace.Segment{}, streamplace.Segment_Audio{}, streamplace.Segment_Video{}, diff --git a/pkg/media/manifest_builder.go b/pkg/media/manifest_builder.go index 13dbba6a..ac114d4c 100644 --- a/pkg/media/manifest_builder.go +++ b/pkg/media/manifest_builder.go @@ -110,7 +110,7 @@ func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName strin }) } } else { - log.Warn(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName) + log.Debug(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName) } } diff --git a/pkg/model/livestream.go b/pkg/model/livestream.go index 1cd5bef7..f2a245e7 100644 --- a/pkg/model/livestream.go +++ b/pkg/model/livestream.go @@ -52,6 +52,22 @@ func (m *DBModel) CreateLivestream(ctx context.Context, ls *Livestream) error { }).Create(ls).Error } +func (m *DBModel) GetLivestream(uri string) (*Livestream, error) { + var livestream Livestream + err := m.DB. + Preload("Repo"). + Preload("Post"). + Where("uri = ?", uri). + First(&livestream).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("error retrieving livestream by uri: %w", err) + } + return &livestream, nil +} + // GetLatestLivestreamForRepo returns the most recent livestream for a given repo DID func (m *DBModel) GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) { var livestream Livestream diff --git a/pkg/model/model.go b/pkg/model/model.go index 72bc1439..5fd9e944 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -57,6 +57,7 @@ type Model interface { GetReplies(repoDID string) ([]*bsky.FeedDefs_PostView, error) CreateLivestream(ctx context.Context, ls *Livestream) error + GetLivestream(uri string) (*Livestream, error) GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) GetLivestreamByPostURI(postURI string) (*Livestream, error) GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) diff --git a/pkg/redcircle/redcircle.go b/pkg/redcircle/redcircle.go deleted file mode 100644 index 7733f430..00000000 --- a/pkg/redcircle/redcircle.go +++ /dev/null @@ -1,204 +0,0 @@ -package redcircle - -import ( - "bytes" - "context" - _ "embed" - "errors" - "fmt" - "image" - _ "image/jpeg" - _ "image/png" - "io" - "net/http" - - comatproto "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/indigo/atproto/atdata" - lexutil "github.com/bluesky-social/indigo/lex/util" - "github.com/streamplace/oatproxy/pkg/oatproxy" - - "github.com/bluesky-social/indigo/xrpc" - "github.com/tdewolff/canvas" - "github.com/tdewolff/canvas/renderers" - "stream.place/streamplace/pkg/aqhttp" - "stream.place/streamplace/pkg/constants" - "stream.place/streamplace/pkg/log" - "stream.place/streamplace/pkg/model" -) - -const WIDTH = 1000.0 -const HEIGHT = 1000.0 - -const BORDER_SIZE = 62.0 - -const BorderScaleFactor = WIDTH / (WIDTH - BORDER_SIZE*2) - -//go:embed redcircle.png -var RedCirclePNG []byte - -func GenerateRedCircle(ctx context.Context, profileJPEG []byte) ([]byte, error) { - c := canvas.New(WIDTH, HEIGHT) - reader := bytes.NewReader(RedCirclePNG) - img, _, err := image.Decode(reader) - if err != nil { - return nil, fmt.Errorf("failed to decode image: %w", err) - } - - profileReader := bytes.NewReader(profileJPEG) - profileImg, _, err := image.Decode(profileReader) - if err != nil { - return nil, fmt.Errorf("failed to decode image: %w", err) - } - - scaleFactor := (float64(profileImg.Bounds().Max.X) / float64(WIDTH)) * BorderScaleFactor - - // Draw the decoded image onto the canvas - canvasCtx := canvas.NewContext(c) - canvasCtx.DrawImage(BORDER_SIZE, BORDER_SIZE, profileImg, canvas.DPMM(scaleFactor)) - canvasCtx.DrawImage(0, 0, img, canvas.DPMM(1)) - - var buf bytes.Buffer - - if err := c.Write(&buf, renderers.JPEG()); err != nil { - return nil, fmt.Errorf("failed to render to JPEG: %w", err) - } - return buf.Bytes(), nil -} - -func downloadImage(ctx context.Context, url string) ([]byte, error) { - if url == "" { - return nil, errors.New("empty URL provided") - } - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := aqhttp.Do(ctx, req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, resp.Status) - } - - imageData, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read image data: %w", err) - } - - return imageData, nil -} - -func UpdateProfilePicture(ctx context.Context, repoDID string, client *oatproxy.XrpcClient, mod model.Model) error { - oldProfile, err := mod.GetBskyProfile(ctx, repoDID, false) - if err != nil { - return fmt.Errorf("failed to get old profile: %w", err) - } - if oldProfile == nil { - return fmt.Errorf("no old profile found for repoDID: %s", repoDID) - } - if oldProfile.Avatar == nil { - return fmt.Errorf("no avatar found for old profile") - } - - repo, err := mod.GetRepo(repoDID) - if err != nil { - return fmt.Errorf("failed to get repo: %w", err) - } - if repo == nil { - return fmt.Errorf("no repo found for repoDID: %s", repoDID) - } - - // https://iameli.com/xrpc/com.atproto.sync.getBlob?did=did:plc:2zmxikig2sj7gqaezl5gntae&cid=bafkreia2myy6nak4daop6dnny76ntilhel5wxpcejmvmwj5t3i3npcfifa - avatarURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", repo.PDS, repoDID, oldProfile.Avatar.Ref.String()) - - imageData, err := downloadImage(ctx, avatarURL) - if err != nil { - return fmt.Errorf("failed to download image: %w", err) - } - circleImage, err := GenerateRedCircle(ctx, imageData) - if err != nil { - return fmt.Errorf("failed to generate redcircle: %w", err) - } - var out comatproto.RepoUploadBlob_Output - err = client.Do(ctx, xrpc.Procedure, "image/jpeg", "com.atproto.repo.uploadBlob", nil, bytes.NewReader(circleImage), &out) - if err != nil { - return fmt.Errorf("failed to upload blob: %w", err) - } - if out.Blob.Size == 0 { - return fmt.Errorf("uploaded blob has size 0") - } - var getRecordOut comatproto.RepoGetRecord_Output - err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ - "repo": repoDID, - "collection": "app.bsky.actor.profile", - "rkey": "self", - }, nil, &getRecordOut) - if err != nil { - return fmt.Errorf("failed to get profile record: %w", err) - } - oldAvatar := oldProfile.Avatar - oldProfile.Avatar = out.Blob - buf := bytes.Buffer{} - err = oldProfile.MarshalCBOR(&buf) - if err != nil { - return fmt.Errorf("failed to marshal profile record: %w", err) - } - kv, err := atdata.UnmarshalCBOR(buf.Bytes()) - if err != nil { - return fmt.Errorf("failed to unmarhsal record CBOR: %w", err) - } - kv[constants.BlueskyProfileGoliveKey] = true - kv["place.stream.stashedAvatar"] = oldAvatar - inp := map[string]any{ - "collection": "app.bsky.actor.profile", - "record": kv, - "rkey": "self", - "repo": repoDID, - "swapRecord": getRecordOut.Cid, - } - putOutput := comatproto.RepoPutRecord_Output{} - err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &putOutput) - if err != nil { - return fmt.Errorf("failed to put profile record: %w", err) - } - log.Log(ctx, "uploaded redcircle", "ref", out.Blob.Ref.String(), "size", out.Blob.Size) - return nil -} - -func RestoreProfilePicture(ctx context.Context, repoDID string, client *oatproxy.XrpcClient, mod model.Model) error { - oldProfile, err := mod.GetBskyProfile(ctx, repoDID, false) - if err != nil { - return fmt.Errorf("failed to get old profile: %w", err) - } - if oldProfile == nil { - return fmt.Errorf("no old profile found for repoDID: %s", repoDID) - } - var getRecordOut comatproto.RepoGetRecord_Output - err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ - "repo": repoDID, - "collection": "app.bsky.actor.profile", - "rkey": "self", - }, nil, &getRecordOut) - if err != nil { - return fmt.Errorf("failed to get profile record: %w", err) - } - inp := comatproto.RepoPutRecord_Input{ - Collection: "app.bsky.actor.profile", - Record: &lexutil.LexiconTypeDecoder{Val: oldProfile}, - Rkey: "self", - Repo: repoDID, - SwapRecord: getRecordOut.Cid, - } - putOutput := comatproto.RepoPutRecord_Output{} - err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &putOutput) - if err != nil { - return fmt.Errorf("failed to put profile record: %w", err) - } - log.Log(ctx, "restored profile picture", "cid", putOutput.Cid) - return nil -} diff --git a/pkg/redcircle/redcircle.png b/pkg/redcircle/redcircle.png deleted file mode 100644 index 64ef64e36cdb47fef6c054c6dd2bd7fa54a1804c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26138 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 diff --git a/pkg/redcircle/redcircle_test.go b/pkg/redcircle/redcircle_test.go deleted file mode 100644 index e97b331e..00000000 --- a/pkg/redcircle/redcircle_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package redcircle - -import ( - "context" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestGenerateRedCircle(t *testing.T) { - testCases := []struct { - infile string - outfile string - }{ - {"robot.jpg", "test.jpg"}, - {"scumbag.png", "test_scumbag.jpg"}, - } - - for _, tc := range testCases { - tc := tc // capture for parallel tests - t.Run(tc.infile, func(t *testing.T) { - bs, err := os.ReadFile(tc.infile) - require.NoError(t, err) - img, err := GenerateRedCircle(context.Background(), bs) - require.NoError(t, err) - require.NotNil(t, img) - f, err := os.Create(tc.outfile) - require.NoError(t, err) - defer f.Close() - _, err = f.Write(img) - require.NoError(t, err) - }) - } -} diff --git a/pkg/redcircle/robot.jpg b/pkg/redcircle/robot.jpg deleted file mode 100644 index 089026d64fe1bb58be73967743fb9dc58f4f5364..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18830 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 diff --git a/pkg/redcircle/scumbag.png b/pkg/redcircle/scumbag.png deleted file mode 100644 index ee020e980c602c7ae9baaf74b5a2df814284a321..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78612 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°LpuT6xYv@0qt13ta8ujbrsb}knY7GG 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 VR$!dlmLH%P@pScbS?83{1OP|p9%TRk diff --git a/pkg/redcircle/test.jpg b/pkg/redcircle/test.jpg deleted file mode 100644 index 1bfdbcf7deb1f94a439869a3548e0183f252621e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73910 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 z 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 diff --git a/pkg/redcircle/test_scumbag.jpg b/pkg/redcircle/test_scumbag.jpg deleted file mode 100644 index 83a56ccd8579bd336b5b3965de7b70a9d649e161..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40166 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

NQ#wW6T)0=>kJw-b|mSW3d3dKiNY z9N$M6{YaE@%XrtNX`|V1@uxWYOW2gxe)A8!DR^vjN6%D$jq|MAdHc0x-v3d$|K;a@ zh6($BeW^dB`=7yo{!i}z4C`Y>Mb}@~{?B0kpilm5zV?5H`SCwZ_MdrP|4{5d!}9Zg zTK_X#U;Ixn|5wd_h7<9Bt6r>F{CetthS?t+|E+ym|M~TQ2DRxw=ioM7q_$}P=Q=|6+<-2V(M(f{V#e-i)C5ZC|1b^puH{}~kPzrJ3ysDEAfzx;#jzqU{P z&v1VIpIPzGiuZr$uD|^JpVNPa*BAdYaBTl;SN}x+-`<)Bc}d z|1(VX{&TJz$*J~8URc-FWw$f_=YAIXSNFpIGuZ9_lvzLX+w^#!sYc4=w+dRqVNk3jwE%J@IO{xh6h`Y+uY$+7YXKU{Zp zm5*8f=YA9aXlO&z<7jA)rtQ&^1Y8=9mL&0is|K{(PIH^T;Ap!2oKHW`*uBthu>Eyy zuSl4wme=AvbI*HoHZN>h{^o`I)cV-3pwsSNp$z0=8{DO(SOB*{D9fY*hWDOPQtW?v zs?z_~9{ta-uq!k6M%Sb#&C`?k-@Lwj>D=Rgf6adc{bxvvpEu3i{`<@QhxV;pwAcR4 z(?9ncKiq#k6>1fvvB#gSqgrP6S%=UiylISWDrVcuCm_c+Yd;8YiD}u6X#+?^U5WkUInh?SYDo6%g-by`cd#d!wkOV z{}~!4)wgPVnBQ^!&w;J_e^~23tY7wG-T5~!{xcYUwtwAQ|MSbfZ#8FZ6dxJ?XE4xp z|IffU|Bt47^B#}-XRIsg@3;MD;QE!+Re$rsyQBYhJhFd&>yQ5|{^mU%t0G=ZKfpN2 zN4@Lt`qg>=86Kaj|Ml$tseM7~e8OY{x6XTh;Ys9CpOYb_O4D_>ecu05dP0Tuk?^pO z=h^y8Rf051O8q1~PADyvWBus(pJ9gT{lA~IuhogX`pFe26MoPP1O z6U959=@qP)RJi%_MmNZ{i48^tRu^N%8%x%|?U#6Y?aj_kmCsAQ+?sF3f2F}*YK7}5 zFYT4RRTmd}g{E!^t}jfeG5c7|;MRBN*LjKm3^!LEemKAT{GUVo|5#lAGaL=S@Fjfh z-}&CV|Lt>s{-5D>o%v7Y@Am&Ze(n$aAm3>+*Dm_?TN~$G0oFTr+zKZ*9?1Esw5epR z+UMvi$s0Cwco!?E25g9Vkn*YGh4wW&qpW{^(^HomyKj1U<`U-Ls}IO`+W&Z4SkJxx zN&SZZ3`OyO#jYL>>QRmj?z<)L-+X4HY0=5YSxc5onYF3xV(Il=0yX*{^ZznWsK0%r z{!geyjqb|TmKpE ze~kO-YQG}rv-q#u{cq)W{%4r^VDUrqF8k+m7yM_~_MhQzR^W%pNB%S1-7Np@_J4-8 zai3k~1C~D1|GHWJ@BQ8XI6td*>@lx@*nMNIDZlpFN%n>{+wT}ovbF6^zP|WR>VJlb z^N#=9bNpZ1)#sv5uQ#>2i(dGBX0x!3pU~W_*(X^=0wZN99vvYxdO|IE)S~D7T3+UV zZnnn%_C5N~aN%f3ol0u_kDxF06Tixe1jc*Rsa5tL>;K98a{kv3_kZzLT&>ne;a1GJ z|JeAeoWy^Ii8;3M&kl8ItaE+=?hozj`w)6{SB1Vu_>RtqXg$XQ!+48}(GOq$mNd_v z=a)ZK=hT$Uou6*r&;GaK*H*o^OA035aXD9|wL|5~)z8Nd+yB&vivPRyKf^`;c<1XI z+tD8;WVy=hZx*PTRjVR0aZPS);cdtlPN#RuCo_}undZ`~5|1&7| z8vomS`#*!z^0@Vzn&-+X~eAQl?>}@ug|E;#olsucSUw&Ob{UtEY zPFdh})T?Xu?x&0@p03I=e)ep;cW(V>>-#OY_TR7x%bB}T^h>Jk_Vs7>|2FuOeRuU! zZ|RdIzkgSs;8SgKooKh<*}laacXtPD*uJOXndak2f!~4?V{g`aA3VJA)1BCsCr6wG zwoTgnHU5LW($R|cqkpyE%u{U+bUhSz__O@C%gw)X*XOTOeza`r-^CAZ>IoL>PU$u{ z9=pGEzw`V<+m9ZeH~G_*Pdn;Gzt$Y$eXC#k=i8s03R#iBIKf{#!q@(hxU^wc-@4b= z#V*eOQ@Q_}*C(%E{s*PPznM3Gc^wwJX-i$syxsGkDf~6=KeXGx;MwZB<7W%Qv~N79 zopmqozUu0~;%8oHEZS7#yPB8(aQUx3hm0h0%or&yJnKP;S zdg6!u|J;A`e^>p_a4|QI*V6KOP;KO|V@53*sihjnGY+obDeoBQD*Ar@Bv#SK^5XCH z*8T}hn-FtWXtt_q;HjSX^F>wdzbE^XsAq|U_;lzMGvhyM%{`$0{E z9s9o+Mt)tmp#H@1W%bwZ#Q&05)qkXC^?wGgxc*Oeix+iizvMr1-Vl_P6l#tC?UVk` z;J99DeJZ+uu=Jm_y2Xd(Kk2rw{&NH6lePhN&U}osBZt0y5gh%8Qx#`&%pHOiRj~aDf=ge%j&Q1u745wW-AJ} z;{JopliTc{r1HD}G(M97SrfVU_CvGv-~Kaj&Q=Fi>HYVUYfk@X*vx-ukJ|3_tycTL zm3L3Ocv~>uEy?obo}%Snl>f2DT6oQw8a}VR{YZrWng{B+kKgN0`_IsNzH&vrZt}iO z^DZUMSl+w1@QoY)=anngmluE9awqoUr=?$)%&Pr+bk1R+gW4|t83GEj_Lkd=Zdw24 zKZAtnB|W9RJzj>F3w}n_HT*L@9DXjeUFA--{N{v0ZQ-`o)NhmO?e=$WeCVtFJNu|q zZDZ_7l>;a2Z^X~s?~wg+*T?(KQQ@;!#J@VXUq?fsXa0M0^-m1-_e0-=zD`g)?LT|x z2bsvOZ##~9=Cc0S8~;M|>3+%5dc9-EJ$mYD2v&w&l`=+l#Klk^S*1w%E zasS-fT@v$8bYIRYJbvZLKa-DFEiVJj{_x8Nemg}n5 z`gN-7!Rp3ulZ&3+Kk@4hzX&Lw$esGL>iVX+QUiXZ9!8C1Ew|1)gc|4Z}ri>hx%{}~?I$A7Z7eDt5;`-}RwD!t zNnEyneRuyCt#45%+&%dpB2ONc|76s@_|ME|CV{RM?XMJrqhh^Nt6A^xZi`Tf6!;}j zq$QhwkN2cv^){IoyA?RVfg8kW_2K%X%J6`4A zQrk5@_t1_TfBj0H{by)jfAaOuJKyu$qr$)WH?0YsE_8>vSpDj*`p3JoS1ntlQ@Bd0 z)s;a*w$*B{hpUs8_QDS{W_>ymHa9b0?f$*wAFIRu9sY1^;R?1R6OXvmzn>@mRcT*} zy1M$>s_(~Q<5Tyx^-CAM$y1d({51Z0`O`S(I5)fHrhak9U({%}e6uhQeQ*9lSNxUe zjT-&`3={ty{(G|iOLVw=Z2O`A3_s=H*x#A{pTTu&@dZ#;dy}^Ro$3C=`X}c*f>MS0 z`hO?Y|5){Op3xkY zN5AC0iMJc>m}R)u@7na4@2-S5&+ne6_Mc(7;(vx5{~wp+yW`aVeVy^2VVnHls{Zwt zK3myewK@EIfBhfkzAN=l)XV=f#LoZE;A9##UuUh!dF{u=8>^#eeQs zKn0uPkD1TxrR<;ZE~~$Oxc-Id>#2DzD8Z{tD-Plo5P-q2yWh^4+m#q_k@)l#e|BmoR<)_jhw_cpo+Y zXE5;I|NGg_+A04Trp|kReZ&6GpX{stGZ@cXv`g`Uy~zBZO?T^@|76rNRV-9a&y{+h z)#PbiAwO5{)bdaJcf`jZ`pDw zU3w)wBRXfpt`og498auY@n!dqufAHB{>gtW7u0E}N=cV*_xf7&YkGHk^5=j0hl;}5 z6l<5JzuNzx&UM#=E%Ui*r$5@Za+So#JVoIng@1~knEz{5f2O^6(eCBGzRP}P)f|%- z59XJe*nT^#zUn_i+s{^!F!k=Q^>6ICUWuQ&of^^Q`tNtm>Yu{%53LFc^xfBHlIgY8 z<8s^ErQhcXKb{-^JNuaUcdgrt+&?9KxwR*y)ptF3YNgGqtO%PT7;dxunB6@Bse!L^zx+SLqfGaMbF=p?PW-w5KZ9_@ z->~hU=Cyx&7yF;VKKxPTUeTA%56*48AGGm5!}0$Nho;m>T;2U@8fd3R~T=i#*W>#=e>HfN?)#; za`(|U&;cZ-13G3#%6J@d4I5UAob$C7e^l1`&v0n^;y?W>UM%YR<@^!8&~DYQ_CL*e z`(JneXHX8)+PCmOgTn3gzaIT(IB}_3-2bursi|M`o7SwAKRC;I?;4$BH$9UjRIZ;3 z{j$8t{)y+s{|v8hW}iHLecxaH3l{r)6Ib5+yZ+*A!&}iV4y`brMNvebpKnsyE^<->DSWhe=5)ZXNbA|GyIh4(`VMlpVi)H z`(wKQ?S1ebVgGGh-}zgt_TRQKHJkHxrcUZl_uu@J>|^gQt*9-l=l)v%=DegM_cq17 z!Fl)J%Y}bss58CXvhrkT<%^0vjxE2=F=ghp9R_{{~2ff@U?e$ zd*yp(@#oP03^(_CeeL?W`q-@f-|lypav#mTSUE2yZnpSSmfFJk$98PIYMEK|-EMko z!MxkjZ{se6|J*D7>Yp30NMM}duLO8AG8NR*12rRgIOOa$s65C8*KOI(KQ?}KXUqQg zIQoy#pP64|MGosEuj%}GNxH*5a`AtLeXX^eKfY_f@pN^{TYEgZWB>b&9{IWM(KG7b zKUi8~=UJw{X!TsD6Q#RB!#>S_GM5K!kj>BC|Nhl~hF)>!Z^ds;hlDz>dU<&5-}Qh0 z#eZBjH`zzFRNDXdzx|Khjo#U(2LFt|Q^}6ft&3~@SoP}O`ya*bD{+VgwjR~_>|zx(g>9)4^uH2-H`{C|e~ zkA3UKEBaite%<{UCZTZdPXD=$8H;cE@0L3F@cN(K{dbzSDtuJina$*wzb5`DPjZ&K z?CDi2zA#9gUbNI~o7r58kg!?)SFYP>edPSlFyq_G{|t>q^}QxP<{x2Sw{zuxh6ge8 zA4i4+Kc8M~zdGQb>v!dVACqHkOV@h;XJDHDCv*9csGG0XypsPR@}J>x__|odGUX;?YcD0?may=NAbgIR*^uD4NJo`4o;bH;E6yb+oUdr z4f*f+qj&yic>nM}gOqc1N!6XR{~79e9bK(ARVv?@aW_V8^XdN#wX@o~GuTY(KIjw(}4W{n*ij@9{vCT diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 5b5618e0..ba343b56 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -9,17 +9,16 @@ import ( "github.com/bluesky-social/indigo/api/bsky" "gorm.io/gorm" + "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/integrations/webhook" "stream.place/streamplace/pkg/log" notificationpkg "stream.place/streamplace/pkg/notifications" - "stream.place/streamplace/pkg/redcircle" "stream.place/streamplace/pkg/streamplace" ) var TaskNotification = "notification" var TaskChat = "chat" -var TaskAddRedCircle = "add_red_circle" -var TaskRemoveRedCircle = "remove_red_circle" +var TaskFinalizeLivestream = "finalize_livestream" type NotificationTask struct { Livestream *streamplace.Livestream_LivestreamView @@ -32,12 +31,8 @@ type ChatTask struct { MessageView *streamplace.ChatDefs_MessageView } -type AddRedCircleTask struct { - UserDID string -} - -type RemoveRedCircleTask struct { - UserDID string +type FinalizeLivestreamTask struct { + LivestreamURI string `json:"livestreamURI"` } func (state *StatefulDB) ProcessQueue(ctx context.Context) error { @@ -71,57 +66,26 @@ func (state *StatefulDB) processTask(ctx context.Context, task *AppTask) error { return state.processNotificationTask(ctx, task) case TaskChat: return state.processChatMessageTask(ctx, task) - case TaskAddRedCircle: - return state.processAddRedCircleTask(ctx, task) - case TaskRemoveRedCircle: - return state.processRemoveRedCircleTask(ctx, task) + case TaskFinalizeLivestream: + return state.processFinalizeLivestreamTask(ctx, task) default: return fmt.Errorf("unknown task type: %s", task.Type) } } -func (state *StatefulDB) processAddRedCircleTask(ctx context.Context, task *AppTask) error { - var addRedCircleTask AddRedCircleTask - if err := json.Unmarshal(task.Payload, &addRedCircleTask); err != nil { - return err - } - repoDID := addRedCircleTask.UserDID - session, err := state.GetSessionByDID(repoDID) - if err != nil { - return fmt.Errorf("failed to get session: %w", err) - } - if session == nil { - return fmt.Errorf("no session found for repoDID: %s", repoDID) - } - session, err = state.OATProxy.RefreshIfNeeded(session) - if err != nil { - return fmt.Errorf("failed to refresh session: %w", err) - } - client, err := state.OATProxy.GetXrpcClient(session) - if err != nil { - return fmt.Errorf("failed to get xrpc client: %w", err) - } - err = redcircle.UpdateProfilePicture(ctx, repoDID, client, state.model) - if err != nil { - return fmt.Errorf("failed to update profile picture: %w", err) - } - return nil -} - -func (state *StatefulDB) processRemoveRedCircleTask(ctx context.Context, task *AppTask) error { - var removeRedCircleTask RemoveRedCircleTask - if err := json.Unmarshal(task.Payload, &removeRedCircleTask); err != nil { +func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task *AppTask) error { + var finalizeLivestreamTask FinalizeLivestreamTask + if err := json.Unmarshal(task.Payload, &finalizeLivestreamTask); err != nil { return err } - userDID := removeRedCircleTask.UserDID - lastLivestream, err := state.model.GetLatestLivestreamForRepo(userDID) + livestream, err := state.model.GetLivestream(finalizeLivestreamTask.LivestreamURI) if err != nil { return fmt.Errorf("failed to get latest livestream for userDID: %w", err) } - if lastLivestream == nil { - return fmt.Errorf("no livestream found for userDID: %s", userDID) + if livestream == nil { + return fmt.Errorf("no livestream found for URI: %s", finalizeLivestreamTask.LivestreamURI) } - lastLivestreamView, err := lastLivestream.ToLivestreamView() + lastLivestreamView, err := livestream.ToLivestreamView() if err != nil { return fmt.Errorf("failed to convert livestream to streamplace livestream: %w", err) } @@ -136,11 +100,11 @@ func (state *StatefulDB) processRemoveRedCircleTask(ctx context.Context, task *A if err != nil { return fmt.Errorf("could not parse last seen at: %w", err) } - if time.Since(lastSeenTime) < 60*time.Second { + if time.Since(lastSeenTime) < constants.LivestreamConsideredInactiveAfter { log.Warn(ctx, "livestream is active, skipping removal of red circle", "lastSeenAt", lastSeenTime) return nil } - session, err := state.GetSessionByDID(userDID) + session, err := state.GetSessionByDID(livestream.RepoDID) if err != nil { return fmt.Errorf("failed to get session: %w", err) } @@ -148,14 +112,11 @@ func (state *StatefulDB) processRemoveRedCircleTask(ctx context.Context, task *A if err != nil { return fmt.Errorf("failed to refresh session: %w", err) } - client, err := state.OATProxy.GetXrpcClient(session) + _, err = state.OATProxy.GetXrpcClient(session) if err != nil { return fmt.Errorf("failed to get xrpc client: %w", err) } - err = redcircle.RestoreProfilePicture(ctx, userDID, client, state.model) - if err != nil { - return fmt.Errorf("failed to restore profile picture: %w", err) - } + log.Warn(ctx, "livestream is inactive, finalizing", "uri", livestream.URI, "lastSeenAt", lastSeenTime) return nil } diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index 30d7a6bb..a1502c49 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -250,7 +250,7 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 11 + fieldCount := 10 if t.Agent == nil { fieldCount-- @@ -260,10 +260,6 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { fieldCount-- } - if t.IntegrationSettings == nil { - fieldCount-- - } - if t.LastSeenAt == nil { fieldCount-- } @@ -519,25 +515,6 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } } - // t.IntegrationSettings (streamplace.Livestream_IntegrationSettings) (struct) - if t.IntegrationSettings != nil { - - if len("integrationSettings") > 1000000 { - return xerrors.Errorf("Value in field \"integrationSettings\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("integrationSettings"))); err != nil { - return err - } - if _, err := cw.WriteString(string("integrationSettings")); err != nil { - return err - } - - if err := t.IntegrationSettings.MarshalCBOR(cw); err != nil { - return err - } - } - // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) if t.NotificationSettings != nil { @@ -757,26 +734,6 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { t.CanonicalUrl = (*string)(&sval) } } - // t.IntegrationSettings (streamplace.Livestream_IntegrationSettings) (struct) - case "integrationSettings": - - { - - b, err := cr.ReadByte() - if err != nil { - return err - } - if b != cbg.CborNull[0] { - if err := cr.UnreadByte(); err != nil { - return err - } - t.IntegrationSettings = new(Livestream_IntegrationSettings) - if err := t.IntegrationSettings.UnmarshalCBOR(cr); err != nil { - return xerrors.Errorf("unmarshaling t.IntegrationSettings pointer: %w", err) - } - } - - } // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) case "notificationSettings": @@ -937,135 +894,6 @@ func (t *Livestream_NotificationSettings) UnmarshalCBOR(r io.Reader) (err error) return nil } -func (t *Livestream_IntegrationSettings) MarshalCBOR(w io.Writer) error { - if t == nil { - _, err := w.Write(cbg.CborNull) - return err - } - - cw := cbg.NewCborWriter(w) - fieldCount := 1 - - if t.UpdateBskyProfile == nil { - fieldCount-- - } - - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { - return err - } - - // t.UpdateBskyProfile (bool) (bool) - if t.UpdateBskyProfile != nil { - - if len("updateBskyProfile") > 1000000 { - return xerrors.Errorf("Value in field \"updateBskyProfile\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updateBskyProfile"))); err != nil { - return err - } - if _, err := cw.WriteString(string("updateBskyProfile")); err != nil { - return err - } - - if t.UpdateBskyProfile == nil { - if _, err := cw.Write(cbg.CborNull); err != nil { - return err - } - } else { - if err := cbg.WriteBool(w, *t.UpdateBskyProfile); err != nil { - return err - } - } - } - return nil -} - -func (t *Livestream_IntegrationSettings) UnmarshalCBOR(r io.Reader) (err error) { - *t = Livestream_IntegrationSettings{} - - cr := cbg.NewCborReader(r) - - maj, extra, err := cr.ReadHeader() - if err != nil { - return err - } - defer func() { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - }() - - if maj != cbg.MajMap { - return fmt.Errorf("cbor input should be of type map") - } - - if extra > cbg.MaxLength { - return fmt.Errorf("Livestream_IntegrationSettings: map struct too large (%d)", extra) - } - - n := extra - - nameBuf := make([]byte, 17) - for i := uint64(0); i < n; i++ { - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) - if err != nil { - return err - } - - if !ok { - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { - return err - } - continue - } - - switch string(nameBuf[:nameLen]) { - // t.UpdateBskyProfile (bool) (bool) - case "updateBskyProfile": - - { - b, err := cr.ReadByte() - if err != nil { - return err - } - if b != cbg.CborNull[0] { - if err := cr.UnreadByte(); err != nil { - return err - } - - maj, extra, err = cr.ReadHeader() - if err != nil { - return err - } - if maj != cbg.MajOther { - return fmt.Errorf("booleans must be major type 7") - } - - var val bool - switch extra { - case 20: - val = false - case 21: - val = true - default: - return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) - } - t.UpdateBskyProfile = &val - } - } - - default: - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { - return err - } - } - } - - return nil -} func (t *Segment) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/pkg/streamplace/streamlivestream.go b/pkg/streamplace/streamlivestream.go index 50832c11..f751503c 100644 --- a/pkg/streamplace/streamlivestream.go +++ b/pkg/streamplace/streamlivestream.go @@ -24,8 +24,7 @@ type Livestream struct { // canonicalUrl: The primary URL where this livestream can be viewed, if available. CanonicalUrl *string `json:"canonicalUrl,omitempty" cborgen:"canonicalUrl,omitempty"` // createdAt: Client-declared timestamp when this livestream started. - CreatedAt string `json:"createdAt" cborgen:"createdAt"` - IntegrationSettings *Livestream_IntegrationSettings `json:"integrationSettings,omitempty" cborgen:"integrationSettings,omitempty"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` // lastSeenAt: Client-declared timestamp when this livestream was last seen by the user. LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"` NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"` @@ -38,12 +37,6 @@ type Livestream struct { Url *string `json:"url,omitempty" cborgen:"url,omitempty"` } -// Livestream_IntegrationSettings is a "integrationSettings" in the place.stream.livestream schema. -type Livestream_IntegrationSettings struct { - // updateBskyProfile: Whether this livestream should update the Bluesky profile picture & pinned post. - UpdateBskyProfile *bool `json:"updateBskyProfile,omitempty" cborgen:"updateBskyProfile,omitempty"` -} - // Livestream_LivestreamView is a "livestreamView" in the place.stream.livestream schema. type Livestream_LivestreamView struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#livestreamView"` -- 2.51.2 From f4874139f86ec774a00a740a1ba2f03207946255 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 17 Feb 2026 15:26:24 -0800 Subject: [PATCH 15/49] prelive: add logic for endedAt in livestream --- .../live-dashboard/livestream-panel.tsx | 24 ++++++++ .../src/streamplace-store/stream.tsx | 39 ++++++++++++ .../lex-reference/place-stream-livestream.md | 10 +++- lexicons/place/stream/livestream.json | 7 ++- pkg/statedb/queue_processor.go | 36 ++++++++++- pkg/streamplace/cbor_gen.go | 59 ++++++++++++++++++- pkg/streamplace/streamlivestream.go | 4 +- 7 files changed, 172 insertions(+), 7 deletions(-) diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index f0c38e21..b945bdab 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -13,6 +13,7 @@ import { Textarea, Tooltip, useCreateStreamRecord, + useEndLivestream, useLivestream, useToast, useUpdateStreamRecord, @@ -211,6 +212,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { const livestream = useLivestream(); const createStreamRecord = useCreateStreamRecord(); const updateStreamRecord = useUpdateStreamRecord(); + const endLivestream = useEndLivestream(); const url = useUrl(); const [title, setTitle] = useState(""); @@ -348,6 +350,11 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { livestream, ]); + const handleEndLivestream = useCallback(async () => { + if (!livestream) return; + await endLivestream(livestream); + }, [livestream, endLivestream]); + const handleImageSelect = useCallback(() => { // Default web file picker behavior const input = document.createElement("input"); @@ -660,6 +667,23 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { {buttonText} + )} diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index 830fb6ce..59bb2d5d 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -341,3 +341,42 @@ export function useUpdateStreamRecord(customUrl: string | null = null) { return record; }; } + +export function useEndLivestream() { + let agent = usePDSAgent(); + return async (livestream: LivestreamViewHydrated | null) => { + if (!agent) { + throw new Error("No PDS agent found"); + } + + if (!agent.did) { + throw new Error("No user DID found, assuming not logged in"); + } + + if (!livestream) { + throw new Error("No latest record"); + } + + let rkey = livestream.uri.split("/").pop(); + if (!rkey) { + throw new Error("No rkey?"); + } + + if (livestream.record.endedAt) { + throw new Error("Livestream already ended"); + } + + let record: PlaceStreamLivestream.Record = { + ...livestream.record, + endedAt: new Date().toISOString(), + }; + + await agent.com.atproto.repo.putRecord({ + repo: agent.did, + collection: "place.stream.livestream", + rkey, + record, + }); + return record; + }; +} diff --git a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md index 3da8804c..15ae29d5 100644 --- a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md +++ b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md @@ -24,7 +24,8 @@ Record announcing a livestream is happening | `title` | `string` | ✅ | The title of the livestream, as it will be announced to followers. | Max Length: 1400
Max Graphemes: 140 | | `url` | `string` | ❌ | The URL where this stream can be found. This is primarily a hint for other Streamplace nodes to locate and replicate the stream. | Format: `uri` | | `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` | -| `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the user. | Format: `datetime` | +| `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the Streamplace station. | Format: `datetime` | +| `endedAt` | `string` | ❌ | Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. | Format: `datetime` | | `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | | | `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: ` / ` e.g. Streamplace/0.7.5 iOS | | | `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | @@ -161,7 +162,12 @@ Record announcing a livestream is happening "lastSeenAt": { "type": "string", "format": "datetime", - "description": "Client-declared timestamp when this livestream was last seen by the user." + "description": "Client-declared timestamp when this livestream was last seen by the Streamplace station." + }, + "endedAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again." }, "post": { "type": "ref", diff --git a/lexicons/place/stream/livestream.json b/lexicons/place/stream/livestream.json index 561f56c1..0afa9684 100644 --- a/lexicons/place/stream/livestream.json +++ b/lexicons/place/stream/livestream.json @@ -29,7 +29,12 @@ "lastSeenAt": { "type": "string", "format": "datetime", - "description": "Client-declared timestamp when this livestream was last seen by the user." + "description": "Client-declared timestamp when this livestream was last seen by the Streamplace station." + }, + "endedAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again." }, "post": { "type": "ref", diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index ba343b56..3e9b577f 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -8,12 +8,17 @@ import ( "time" "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/bluesky-social/indigo/xrpc" "gorm.io/gorm" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/integrations/webhook" "stream.place/streamplace/pkg/log" notificationpkg "stream.place/streamplace/pkg/notifications" "stream.place/streamplace/pkg/streamplace" + + comatproto "github.com/bluesky-social/indigo/api/atproto" ) var TaskNotification = "notification" @@ -112,11 +117,38 @@ func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task if err != nil { return fmt.Errorf("failed to refresh session: %w", err) } - _, err = state.OATProxy.GetXrpcClient(session) + client, err := state.OATProxy.GetXrpcClient(session) if err != nil { return fmt.Errorf("failed to get xrpc client: %w", err) } - log.Warn(ctx, "livestream is inactive, finalizing", "uri", livestream.URI, "lastSeenAt", lastSeenTime) + if rec.EndedAt != nil { + log.Warn(ctx, "livestream has already ended, skipping", "uri", livestream.URI, "endedAt", *rec.EndedAt) + return nil + } + + uri, err := syntax.ParseATURI(livestream.URI) + if err != nil { + return fmt.Errorf("failed to parse ATURI: %w", err) + } + + rec.EndedAt = rec.LastSeenAt + + inp := comatproto.RepoPutRecord_Input{ + Collection: "place.stream.livestream", + Record: &lexutil.LexiconTypeDecoder{Val: rec}, + Rkey: uri.RecordKey().String(), + Repo: livestream.RepoDID, + SwapRecord: &livestream.CID, + } + out := comatproto.RepoPutRecord_Output{} + + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &out) + if err != nil { + return fmt.Errorf("failed to update livestream record: %w", err) + } + + log.Log(ctx, "livestream finalized", "uri", livestream.URI, "endedAt", *rec.EndedAt) + return nil } diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index a1502c49..628ae01e 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -250,7 +250,7 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 10 + fieldCount := 11 if t.Agent == nil { fieldCount-- @@ -260,6 +260,10 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.EndedAt == nil { + fieldCount-- + } + if t.LastSeenAt == nil { fieldCount-- } @@ -428,6 +432,38 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { return err } + // t.EndedAt (string) (string) + if t.EndedAt != nil { + + if len("endedAt") > 1000000 { + return xerrors.Errorf("Value in field \"endedAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("endedAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("endedAt")); err != nil { + return err + } + + if t.EndedAt == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.EndedAt) > 1000000 { + return xerrors.Errorf("Value in field t.EndedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.EndedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.EndedAt)); err != nil { + return err + } + } + } + // t.CreatedAt (string) (string) if len("createdAt") > 1000000 { return xerrors.Errorf("Value in field \"createdAt\" was too long") @@ -681,6 +717,27 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { t.Title = string(sval) } + // t.EndedAt (string) (string) + case "endedAt": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.EndedAt = (*string)(&sval) + } + } // t.CreatedAt (string) (string) case "createdAt": diff --git a/pkg/streamplace/streamlivestream.go b/pkg/streamplace/streamlivestream.go index f751503c..6b4b481f 100644 --- a/pkg/streamplace/streamlivestream.go +++ b/pkg/streamplace/streamlivestream.go @@ -25,7 +25,9 @@ type Livestream struct { CanonicalUrl *string `json:"canonicalUrl,omitempty" cborgen:"canonicalUrl,omitempty"` // createdAt: Client-declared timestamp when this livestream started. CreatedAt string `json:"createdAt" cborgen:"createdAt"` - // lastSeenAt: Client-declared timestamp when this livestream was last seen by the user. + // endedAt: Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. + EndedAt *string `json:"endedAt,omitempty" cborgen:"endedAt,omitempty"` + // lastSeenAt: Client-declared timestamp when this livestream was last seen by the Streamplace station. LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"` NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"` // post: The post that announced this livestream. -- 2.51.2 From eeace624f7f5d885bd7775d49bd5a7e75197bcf7 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 17 Feb 2026 15:57:12 -0800 Subject: [PATCH 16/49] media: don't add published action for unpublished streams --- pkg/media/manifest_builder.go | 86 ++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/pkg/media/manifest_builder.go b/pkg/media/manifest_builder.go index ac114d4c..c3902b6f 100644 --- a/pkg/media/manifest_builder.go +++ b/pkg/media/manifest_builder.go @@ -45,10 +45,58 @@ func toObj(record any) (obj, error) { return o, nil } +func (mb *ManifestBuilder) getLivestream(ctx context.Context, streamerName string) (*streamplace.Livestream, error) { + if mb.model == nil { + return nil, fmt.Errorf("model is nil") + } + livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve livestream: %w", err) + } + if livestream == nil { + return nil, nil + } + livestreamRecord, err := livestream.ToLivestreamView() + if err != nil { + return nil, fmt.Errorf("failed to convert livestream to view: %w", err) + } + ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream) + if !ok { + return nil, fmt.Errorf("livestream is not a streamplace livestream") + } + return ls, nil +} + func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName string, start int64) ([]byte, error) { log.Debug(ctx, "🔍 BuildManifest ENTRY", "streamer", streamerName, "start", start) + + shouldPublish := false + + // Add livestream title if available + livestreamTitle := "unpublished livestream" // default fallback + ls, err := mb.getLivestream(ctx, streamerName) + if err != nil { + return nil, fmt.Errorf("failed to get livestream: %w", err) + } + if ls != nil { + livestreamTitle = ls.Title + shouldPublish = ls.EndedAt == nil + } + // Start with base manifest startTime := aqtime.FromMillis(start).String() + actions := []obj{ + { + "action": "c2pa.created", + "when": startTime, + }, + } + if shouldPublish { + actions = append(actions, obj{ + "action": "c2pa.published", + "when": startTime, + }) + } mani := obj{ "title": fmt.Sprintf("Livestream Segment at %s", startTime), "assertions": []obj{ @@ -56,16 +104,7 @@ func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName strin { "label": "c2pa.actions", "data": obj{ - "actions": []obj{ - { - "action": "c2pa.created", - "when": startTime, - }, - { - "action": "c2pa.published", - "when": startTime, - }, - }, + "actions": actions, }, }, // Content metadata, with extra custom fields added later @@ -114,33 +153,6 @@ func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName strin } } - // Add livestream title if available - livestreamTitle := "livestream" // default fallback - if mb.model != nil { - livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName) - if err != nil { - log.Warn(ctx, "ManifestBuilder: failed to retrieve livestream, using default title", "error", err, "did", streamerName) - } else if livestream != nil { - // Extract title from livestream record - livestreamRecord, err := livestream.ToLivestreamView() - if err != nil { - log.Warn(ctx, "ManifestBuilder: failed to convert livestream to view, using default title", "error", err, "did", streamerName) - } else { - if ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream); ok { - livestreamTitle = ls.Title - livestreamObj, err := toObj(ls) - if err != nil { - return nil, fmt.Errorf("failed to marshal livestream: %w", err) - } - mani["assertions"] = append(mani["assertions"].([]obj), obj{ - "label": "place.stream.livestream", - "data": livestreamObj, - }) - } - } - } - } - // Update the manifest title with the retrieved livestream title mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = livestreamTitle -- 2.51.2 From 6d33ec42c7d069992795bb45b4c7eecf51f01127 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:30:10 -0600 Subject: [PATCH 17/49] user profile card including badge display --- js/components/src/components/chat/badge.tsx | 43 +++ .../src/components/chat/chat-message.tsx | 54 ++-- js/components/src/components/chat/chat.tsx | 41 +-- .../src/components/chat/user-profile-card.tsx | 256 ++++++++++++++++++ 4 files changed, 340 insertions(+), 54 deletions(-) create mode 100644 js/components/src/components/chat/badge.tsx create mode 100644 js/components/src/components/chat/user-profile-card.tsx diff --git a/js/components/src/components/chat/badge.tsx b/js/components/src/components/chat/badge.tsx new file mode 100644 index 00000000..3b09db5e --- /dev/null +++ b/js/components/src/components/chat/badge.tsx @@ -0,0 +1,43 @@ +import { Image, View } from "react-native"; +import { ChatMessageViewHydrated } from "streamplace"; +import { zero } from "../.."; + +export const BADGE_IMAGES: Record> = { + "place.stream.badge.defs#mod": require("../../../assets/badges/mod.png"), + "place.stream.badge.defs#streamer": require("../../../assets/badges/live.png"), + "place.stream.badge.defs#vip": require("../../../assets/badges/vip.png"), +}; + +export const Badge = ({ + badgeType, + size = 20, +}: { + badgeType: string; + size?: number; +}) => { + const source = + BADGE_IMAGES[badgeType] ?? BADGE_IMAGES["place.stream.badge.defs#vip"]; + return ( + + ); +}; + +export const BadgeDisplayRow = ({ + badges, +}: { + badges: ChatMessageViewHydrated["badges"]; +}) => { + if (!badges?.length) return null; + return ( + + {badges.map((badge, index) => ( + + + + ))} + + ); +}; diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index fac52d2b..2659e96b 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -4,7 +4,7 @@ import { Mention, } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; import { memo, useCallback } from "react"; -import { Image, Linking, View } from "react-native"; +import { Linking, View } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; import { RichtextSegment, segmentize } from "../../lib/facet"; import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; @@ -23,9 +23,10 @@ interface Facet { }>; } -import { zero } from "../.."; import { useLivestreamStore } from "../../livestream-store"; import { Text } from "../ui/text"; +import { BadgeDisplayRow } from "./badge"; +import { UserProfileCard } from "./user-profile-card"; const getRgbColor = (color?: { red: number; green: number; blue: number }) => color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : colors.gray[500]; @@ -171,45 +172,28 @@ export const RenderChatMessage = memo( {formatTime(item.record.createdAt)} )} - {item.badges?.length ? ( - - {item.badges.map((badge, index) => ( - - {badge.badgeType === "place.stream.badge.defs#mod" ? ( - - ) : badge.badgeType === "place.stream.badge.defs#streamer" ? ( - - ) : ( - - )} - - ))} - - ) : null} + - - {formatHandleWithAt(item.author)} - + + {formatHandleWithAt(item.author)} + + :{" "} , drag: SharedValue) { const styleAnimation = useAnimatedStyle(() => { @@ -309,25 +310,27 @@ export function Chat({ }, ].concat(propsStyle || [])} > - } - removeClippedSubviews={true} - maxToRenderPerBatch={10} - initialNumToRender={10} - updateCellsBatchingPeriod={50} - onScroll={handleScroll} - scrollEventThrottle={16} - nestedScrollEnabled={true} - /> + + } + removeClippedSubviews={true} + maxToRenderPerBatch={10} + initialNumToRender={10} + updateCellsBatchingPeriod={50} + onScroll={handleScroll} + scrollEventThrottle={16} + nestedScrollEnabled={true} + /> + = { + "place.stream.badge.defs#mod": { + label: "Moderator", + description: "This user is a moderator.", + issuedBy: "{issuer} p.p. {streamer}", + }, + "place.stream.badge.defs#streamer": { + label: "Streamer", + description: "This user is the streamer.", + }, + "place.stream.badge.defs#vip": { + label: "VIP", + description: "This user is a very important person.", + }, +}; + +interface OpenCardContextValue { + openUri: string | null; + setOpenUri: (uri: string | null) => void; +} + +const OpenCardContext = createContext({ + openUri: null, + setOpenUri: () => {}, +}); + +export const ProfileCardProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [openUri, setOpenUri] = useState(null); + return ( + + {children} + + ); +}; + +const BadgeRow = ({ + badge, + serviceDid, +}: { + badge: NonNullable[number]; + serviceDid: string; +}) => { + const { theme } = useTheme(); + const streamer = useLivestreamStore((x) => x.livestream?.author); + const isServiceIssued = badge.issuer === serviceDid; + const issuerDids = useMemo( + () => (isServiceIssued ? [] : [badge.issuer]), + [isServiceIssued, badge.issuer], + ); + const issuerProfiles = useAvatars(issuerDids); + const meta = BADGE_META[badge.badgeType]; + + if (!meta) return null; + + let issuerLabel = isServiceIssued + ? "Streamplace" + : issuerProfiles[badge.issuer]?.handle + ? `@${issuerProfiles[badge.issuer].handle}` + : badge.issuer; + console.log("meta", meta); + if (meta.issuedBy) { + console.log("issued by"); + issuerLabel = meta.issuedBy + .replace("{issuer}", issuerLabel) + .replace( + "{streamer}", + streamer?.handle ? formatHandleWithAt(streamer) : "the streamer", + ); + } + + return ( + + + + {meta.label} + + Issued by {issuerLabel} + + + {meta.description} + + + + ); +}; + +export const UserProfileCard = ({ + uri, + author, + badges, + children, +}: { + uri: string; + author: ProfileViewBasic; + badges: ChatMessageViewHydrated["badges"]; + children: React.ReactNode; +}) => { + const { theme } = useTheme(); + const nodeUrl = useUrl(); + const serviceDid = nodeUrl + ? `did:web:${nodeUrl.replace(/^https?:\/\//, "")}` + : null; + + const { openUri, setOpenUri } = useContext(OpenCardContext); + const isOpen = openUri === uri; + const thisRef = useRef(null); + + const profiles = useAvatars(author.did ? [author.did] : []); + const profile = profiles[author.did]; + + useEffect(() => { + isOpen ? thisRef.current?.open() : thisRef.current?.close(); + }, [isOpen]); + + const onOpenChange = useCallback( + (open: boolean) => { + setOpenUri(open ? uri : null); + }, + [uri, setOpenUri], + ); + + const serviceBadges = useMemo( + () => badges?.filter((b) => serviceDid && b.issuer === serviceDid) ?? [], + [badges, serviceDid], + ); + + return ( + + + {children} + + + + {profile?.banner ? ( + + ) : ( + + )} + + {profile?.avatar ? ( + + ) : ( + + )} + + + @{author.handle} + {profile?.description ? ( + + {profile.description} + + ) : null} + + {serviceBadges.length > 0 && serviceDid ? ( + + {serviceBadges.map((badge, i) => ( + + ))} + + ) : null} + + + + ); +}; -- 2.51.2 From 78fc519d554b9a40973f51199dcab8958e89e125 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:02:18 -0600 Subject: [PATCH 18/49] it looks how i want --- js/components/src/components/chat/badge.tsx | 20 ++++++----- .../src/components/chat/chat-message.tsx | 36 ++++++++----------- .../src/components/chat/user-profile-card.tsx | 36 +++++++++++++++++-- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/js/components/src/components/chat/badge.tsx b/js/components/src/components/chat/badge.tsx index 3b09db5e..0fa65338 100644 --- a/js/components/src/components/chat/badge.tsx +++ b/js/components/src/components/chat/badge.tsx @@ -1,6 +1,5 @@ -import { Image, View } from "react-native"; +import { Image } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; -import { zero } from "../.."; export const BADGE_IMAGES: Record> = { "place.stream.badge.defs#mod": require("../../../assets/badges/mod.png"), @@ -10,7 +9,7 @@ export const BADGE_IMAGES: Record> = { export const Badge = ({ badgeType, - size = 20, + size = 18, }: { badgeType: string; size?: number; @@ -20,7 +19,12 @@ export const Badge = ({ return ( ); }; @@ -32,12 +36,10 @@ export const BadgeDisplayRow = ({ }) => { if (!badges?.length) return null; return ( - + <> {badges.map((badge, index) => ( - - - + ))} - + ); }; diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 2659e96b..e85ac5c9 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -154,47 +154,41 @@ export const RenderChatMessage = memo( )} - + {showTime && ( {formatTime(item.record.createdAt)} )} - - + - + + - {formatHandleWithAt(item.author)} + }} + > + {formatHandleWithAt(item.author)} + - :{" "} + + {": "} + (null); + const [hovered, setHovered] = useState(false); const profiles = useAvatars(author.did ? [author.did] : []); const profile = profiles[author.did]; @@ -170,7 +171,38 @@ export const UserProfileCard = ({ return ( - {children} + {}} + {...(Platform.OS === "web" + ? { + onHoverIn: () => setHovered(true), + onHoverOut: () => setHovered(false), + } + : {})} + style={ + Platform.OS === "web" && hovered + ? { + backgroundColor: "rgba(255,255,255,0.15)", + borderRadius: 6, + paddingHorizontal: 3, + flexDirection: "row", + gap: 4, + marginLeft: -3, + paddingLeft: 3, + marginRight: -2, + } + : { + paddingHorizontal: 3, + flexDirection: "row", + gap: 4, + marginLeft: -3, + paddingLeft: 3, + marginRight: -2, + } + } + > + {children} + -- 2.51.2 From 1fe05f07e729fedb819045acf9e82a7978e7b855 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:04:52 -0600 Subject: [PATCH 19/49] clean up --- js/components/src/components/chat/badge.tsx | 6 +-- .../src/components/chat/user-profile-card.tsx | 37 ++++++------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/js/components/src/components/chat/badge.tsx b/js/components/src/components/chat/badge.tsx index 0fa65338..0d5ea030 100644 --- a/js/components/src/components/chat/badge.tsx +++ b/js/components/src/components/chat/badge.tsx @@ -14,8 +14,8 @@ export const Badge = ({ badgeType: string; size?: number; }) => { - const source = - BADGE_IMAGES[badgeType] ?? BADGE_IMAGES["place.stream.badge.defs#vip"]; + const source = BADGE_IMAGES[badgeType]; + if (!source) return null; return ( {badges.map((badge, index) => ( - + ))} ); diff --git a/js/components/src/components/chat/user-profile-card.tsx b/js/components/src/components/chat/user-profile-card.tsx index e5f4f9e2..1a052cea 100644 --- a/js/components/src/components/chat/user-profile-card.tsx +++ b/js/components/src/components/chat/user-profile-card.tsx @@ -27,7 +27,7 @@ import { Badge } from "./badge"; interface BadgeMeta { label: string; description: string; - issuedBy?: string; // Optional template for issuer display + issuedBy?: string; } const BADGE_META: Record = { @@ -76,7 +76,6 @@ const BadgeRow = ({ badge: NonNullable[number]; serviceDid: string; }) => { - const { theme } = useTheme(); const streamer = useLivestreamStore((x) => x.livestream?.author); const isServiceIssued = badge.issuer === serviceDid; const issuerDids = useMemo( @@ -93,9 +92,7 @@ const BadgeRow = ({ : issuerProfiles[badge.issuer]?.handle ? `@${issuerProfiles[badge.issuer].handle}` : badge.issuer; - console.log("meta", meta); if (meta.issuedBy) { - console.log("issued by"); issuerLabel = meta.issuedBy .replace("{issuer}", issuerLabel) .replace( @@ -179,27 +176,17 @@ export const UserProfileCard = ({ onHoverOut: () => setHovered(false), } : {})} - style={ - Platform.OS === "web" && hovered - ? { - backgroundColor: "rgba(255,255,255,0.15)", - borderRadius: 6, - paddingHorizontal: 3, - flexDirection: "row", - gap: 4, - marginLeft: -3, - paddingLeft: 3, - marginRight: -2, - } - : { - paddingHorizontal: 3, - flexDirection: "row", - gap: 4, - marginLeft: -3, - paddingLeft: 3, - marginRight: -2, - } - } + style={{ + paddingHorizontal: 3, + flexDirection: "row", + gap: 4, + marginLeft: -3, + paddingLeft: 3, + marginRight: -2, + ...(Platform.OS === "web" && hovered + ? { backgroundColor: "rgba(255,255,255,0.15)", borderRadius: 6 } + : {}), + }} > {children} -- 2.51.2 From 24f3496649bf1078d04df578216d03f6ed336292 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:28:02 -0600 Subject: [PATCH 20/49] add a gradient thingy --- js/app/components/mobile/desktop-ui.tsx | 31 ++-- .../mobile/desktop-ui/bottom-controls.tsx | 153 +++++++++++------- js/app/components/ui/gradient.tsx | 77 +++++++++ 3 files changed, 186 insertions(+), 75 deletions(-) create mode 100644 js/app/components/ui/gradient.tsx diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx index 8cf2b6b1..f1508c1d 100644 --- a/js/app/components/mobile/desktop-ui.tsx +++ b/js/app/components/mobile/desktop-ui.tsx @@ -10,6 +10,7 @@ import { View, zero, } from "@streamplace/components"; +import { AnimatedGradient } from "components/ui/gradient"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -228,24 +229,24 @@ export function DesktopUi({ layout.position.absolute, position.bottom[0], w.percent[100], - { - backgroundColor: "rgba(0, 0, 0, 0.6)", - paddingHorizontal: 16, - paddingVertical: 2, - paddingBottom: 2, - }, animatedFadeStyle, ]} > - + + + {isSelfAndNotLive && ( diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx index 2f33bd0d..c3cbe94b 100644 --- a/js/app/components/mobile/desktop-ui/bottom-controls.tsx +++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx @@ -17,11 +17,10 @@ import { PictureInPicture2, } from "lucide-react-native"; import { Platform, Pressable } from "react-native"; -import { VolumeSlider } from "./volume-slider"; - import { Mu } from "./mu"; +import { VolumeSlider } from "./volume-slider"; -const { gap, layout, p, r, py, px } = zero; +const { gap, layout, p, r, px } = zero; interface BottomControlBarProps { ingest: string | null; @@ -33,6 +32,89 @@ interface BottomControlBarProps { setShowChat?: (show: boolean) => void; } +function PipButton({ + pipActive, + onHandlePip, +}: { + pipActive: boolean; + onHandlePip: () => void; +}) { + const { theme } = useTheme(); + if (Platform.OS !== "web") return null; + return ( + + + + + + ); +} + +function DanmuButton() { + const { theme } = useTheme(); + const danmuUnlocked = useDanmuUnlocked(); + const danmuEnabled = useDanmuEnabled(); + const setDanmuEnabled = useSetDanmuEnabled(); + if (!danmuUnlocked) return null; + return ( + setDanmuEnabled(!danmuEnabled)} + style={[px[2], r[1]]} + > + + + ); +} + +function ContextMenuButton({ + dropdownPortalContainer, +}: { + dropdownPortalContainer?: any; +}) { + return ( + + ); +} + +function FullscreenButton() { + const { theme } = useTheme(); + const fullscreen = usePlayerStore((state) => state.fullscreen); + const setFullscreen = usePlayerStore((state) => state.setFullscreen); + if (Platform.OS !== "web") return null; + return ( + setFullscreen(!fullscreen)} style={[p[2], r[1]]}> + {fullscreen ? ( + + ) : ( + + )} + + ); +} + +function CollapseChatButton({ + showChat, + setShowChat, +}: { + showChat: boolean; + setShowChat: (show: boolean) => void; +}) { + if (Platform.OS === "web") return null; + return ( + + ); +} + export function BottomControlBar({ ingest, pipSupported, @@ -42,19 +124,13 @@ export function BottomControlBar({ showChat, setShowChat, }: BottomControlBarProps) { - let { theme } = useTheme(); - const fullscreen = usePlayerStore((state) => state.fullscreen); - const setFullscreen = usePlayerStore((state) => state.setFullscreen); - const danmuUnlocked = useDanmuUnlocked(); - const danmuEnabled = useDanmuEnabled(); - const setDanmuEnabled = useSetDanmuEnabled(); - return ( @@ -62,61 +138,18 @@ export function BottomControlBar({ - {Platform.OS === "web" && pipSupported && ( - - - - - - )} - {danmuUnlocked && ( - { - setDanmuEnabled(!danmuEnabled); - }} - style={[px[0], r[1]]} - > - - + {pipSupported && ( + )} + {ingest === null && ( - )} - {Platform.OS === "web" && ( - { - setFullscreen(!fullscreen); - }} - style={[p[2], r[1]]} - > - {fullscreen ? ( - - ) : ( - - )} - - )} - {/* if not web, then add the collapse chat controls here */} - {Platform.OS !== "web" && setShowChat && ( - + + {setShowChat && ( + )} diff --git a/js/app/components/ui/gradient.tsx b/js/app/components/ui/gradient.tsx new file mode 100644 index 00000000..a2fb2f87 --- /dev/null +++ b/js/app/components/ui/gradient.tsx @@ -0,0 +1,77 @@ +// Source - https://stackoverflow.com/a/74182982 +// Posted by TOPKAT, modified by community. See post 'Timeline' for change history +// Retrieved 2026-02-18, License - CC BY-SA 4.0 + +import { DimensionValue, StyleSheet, View, ViewProps } from "react-native"; +import Animated from "react-native-reanimated"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; + +type GradientProps = { + fromColor: string; + toColor: string; + children?: any; + height?: DimensionValue; + opacityColor1?: number; + opacityColor2?: number; +} & ViewProps; + +function Gradient({ + children, + fromColor, + toColor, + height = "100%", + opacityColor1 = 1, + opacityColor2 = 1, + ...otherViewProps +}: GradientProps) { + const gradientUniqueId = `grad${fromColor}+${toColor}`.replace( + /[^a-zA-Z0-9 ]/g, + "", + ); + return ( + <> + + + + + + + + + + + + {children} + + ); +} + +export const AnimatedGradient = Animated.createAnimatedComponent(Gradient); + +export default Gradient; -- 2.51.2 From 8946ae526274dae124bd8bc3531afbb0625ccbdb Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:30:00 -0600 Subject: [PATCH 21/49] various volume thingies --- .../mobile/desktop-ui/mute-overlay.tsx | 12 +++++++++++- .../mobile/desktop-ui/volume-slider.tsx | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/js/app/components/mobile/desktop-ui/mute-overlay.tsx b/js/app/components/mobile/desktop-ui/mute-overlay.tsx index 4a9e78f0..09ed23ff 100644 --- a/js/app/components/mobile/desktop-ui/mute-overlay.tsx +++ b/js/app/components/mobile/desktop-ui/mute-overlay.tsx @@ -1,11 +1,13 @@ import { Text, View, + useMuted, usePlayerStore, useSetMuted, zero, } from "@streamplace/components"; import { VolumeX } from "lucide-react-native"; +import { useEffect } from "react"; import { Pressable } from "react-native"; const { layout, h, w, p, px } = zero; @@ -13,9 +15,17 @@ const { layout, h, w, p, px } = zero; export function MuteOverlay() { const muteWasForced = usePlayerStore((state) => state.muteWasForced); const setMuted = useSetMuted(); + const isMuted = useMuted(); const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); - if (!muteWasForced) return null; + // let's switch muteWasForced to false if the user unmutes lol + useEffect(() => { + if (!isMuted && muteWasForced) { + setMuteWasForced(false); + } + }, [isMuted, muteWasForced, setMuteWasForced]); + + if (!muteWasForced || !isMuted) return null; return ( | null>(null); + + const fadeOut = useCallback(() => { + fadeAnim.value = withTiming(0, { duration: 400 }); + widthAnim.value = withTiming(0, { duration: 400 }); + }, [fadeAnim, widthAnim]); + + const scheduleFadeOut = useCallback(() => { + if (fadeOutTimer.current) clearTimeout(fadeOutTimer.current); + fadeOutTimer.current = setTimeout(fadeOut, 1500); + }, [fadeOut]); const onVolumeHover = useCallback(() => { + if (fadeOutTimer.current) clearTimeout(fadeOutTimer.current); fadeAnim.value = withTiming(1, { duration: 200 }); widthAnim.value = withTiming(200, { duration: 200 }); }, [fadeAnim, widthAnim]); @@ -49,6 +61,7 @@ export function VolumeSlider() { return ( @@ -76,6 +89,7 @@ export function VolumeSlider() { } else { setMuted(false); } + scheduleFadeOut(); }} asChild > -- 2.51.2 From 7b0ad91e24c3d85809e1e629fcd4e78e53d6ec9c Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 18 Feb 2026 15:46:03 -0800 Subject: [PATCH 22/49] playback: initial demo of authenticated playback --- Makefile | 4 +- go.mod | 2 +- go.sum | 4 +- .../components/mobile-player/use-webrtc.tsx | 63 +++++++++----- .../content/docs/lex-reference/openapi.json | 71 ++++++++++++++++ .../playback/place-stream-playback-whep.md | 82 +++++++++++++++++++ lexicons/place/stream/playback/whep.json | 36 ++++++++ pkg/api/api.go | 2 +- pkg/api/playback.go | 2 +- pkg/atproto/atproto.go | 6 +- pkg/atproto/migrate.go | 2 +- pkg/atproto/sync.go | 22 ++--- pkg/bus/segchanman.go | 1 + pkg/director/stream_session.go | 18 ++-- pkg/media/media.go | 33 +++++++- pkg/media/validate.go | 2 +- pkg/spxrpc/place_stream_playback.go | 34 ++++++++ pkg/spxrpc/spxrpc.go | 5 +- pkg/spxrpc/stubs.go | 18 ++++ pkg/streamplace/playbackwhep.go | 30 +++++++ 20 files changed, 385 insertions(+), 52 deletions(-) create mode 100644 js/docs/src/content/docs/lex-reference/playback/place-stream-playback-whep.md create mode 100644 lexicons/place/stream/playback/whep.json create mode 100644 pkg/spxrpc/place_stream_playback.go create mode 100644 pkg/streamplace/playbackwhep.go diff --git a/Makefile b/Makefile index d48cbe18..0c2d62e4 100644 --- a/Makefile +++ b/Makefile @@ -410,7 +410,7 @@ lexgen: .PHONY: lexgen-types lexgen-types: - go run github.com/bluesky-social/indigo/cmd/lexgen \ + go tool github.com/bluesky-social/indigo/cmd/lexgen \ -outdir ./pkg/spxrpc \ --build-file util/lexgen-types.json \ --external-lexicons subprojects/atproto/lexicons \ @@ -420,7 +420,7 @@ lexgen-types: .PHONY: lexgen-server lexgen-server: mkdir -p ./pkg/spxrpc \ - && go run github.com/bluesky-social/indigo/cmd/lexgen \ + && go tool github.com/bluesky-social/indigo/cmd/lexgen \ --gen-server \ --types-import place.stream:stream.place/streamplace/pkg/streamplace \ --types-import app.bsky:github.com/bluesky-social/indigo/api/bsky \ diff --git a/go.mod b/go.mod index 82ac35b1..75fca763 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4 replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 -//replace github.com/livepeer/go-livepeer => ../go-livepeer +replace github.com/bluesky-social/indigo => github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 tool github.com/bluesky-social/indigo/cmd/lexgen diff --git a/go.sum b/go.sum index a2f54da9..feeb902f 100644 --- a/go.sum +++ b/go.sum @@ -219,8 +219,6 @@ github.com/bluenviron/gortsplib/v5 v5.2.1 h1:VrFC5RU8npiyKiqLFKXZmdUFChSmbjO5eZB github.com/bluenviron/gortsplib/v5 v5.2.1/go.mod h1:sK4+00XQaSpU2iPIKjmhj6Yye+sVbNWEU2IJWYEZI9U= github.com/bluenviron/mediacommon/v2 v2.5.2 h1:eq7LHJFksDAVtVdTrwOUl7dO7LE8eKwLgYKYi5MmYaY= github.com/bluenviron/mediacommon/v2 v2.5.2/go.mod h1:5V15TiOfeaNVmZPVuOqAwqQSWyvMV86/dijDKu5q9Zs= -github.com/bluesky-social/indigo v0.0.0-20251206005924-d49b45419635 h1:kNeRrgGJH2g5OvjLqtaQ744YXqduliZYpFkJ/ld47c0= -github.com/bluesky-social/indigo v0.0.0-20251206005924-d49b45419635/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= @@ -1315,6 +1313,8 @@ github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac h github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= +github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI= +github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/js/components/src/components/mobile-player/use-webrtc.tsx b/js/components/src/components/mobile-player/use-webrtc.tsx index 676d79dd..af7495ab 100644 --- a/js/components/src/components/mobile-player/use-webrtc.tsx +++ b/js/components/src/components/mobile-player/use-webrtc.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import * as sdpTransform from "sdp-transform"; -import { PlayerStatus, usePlayerStore, useStreamKey } from "../.."; +import { StreamplaceAgent } from "streamplace"; +import { PlayerStatus, usePDSAgent, usePlayerStore, useStreamKey } from "../.."; import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; export default function useWebRTC( @@ -9,10 +10,14 @@ export default function useWebRTC( const [mediaStream, setMediaStream] = useState(null); const [stuck, setStuck] = useState(false); const setStatus = usePlayerStore((x) => x.setStatus); + let agent = usePDSAgent(); const lastChange = useRef(0); useEffect(() => { + if (!agent) { + return; + } const peerConnection = new RTCPeerConnection({ bundlePolicy: "max-bundle", }); @@ -44,7 +49,12 @@ export default function useWebRTC( } }); peerConnection.addEventListener("negotiationneeded", () => { - negotiateConnectionWithClientOffer(peerConnection, endpoint); + negotiateConnectionWithClientOffer( + peerConnection, + endpoint, + undefined, + agent, + ); }); let lastFramesReceived = 0; @@ -82,7 +92,7 @@ export default function useWebRTC( clearInterval(handle); peerConnection.close(); }; - }, [endpoint]); + }, [endpoint, agent]); return [mediaStream, stuck]; } @@ -102,6 +112,7 @@ export async function negotiateConnectionWithClientOffer( peerConnection: RTCPeerConnection, endpoint: string, bearerToken?: string, + agent?: StreamplaceAgent, ) { /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ const offer = await peerConnection.createOffer({ @@ -134,23 +145,23 @@ export async function negotiateConnectionWithClientOffer( * This specifies how the client should communicate, * and what kind of media client and server have negotiated to exchange. */ - let response = await postSDPOffer(`${endpoint}`, ofr.sdp, bearerToken); - if (response.status === 201) { - let answerSDP = await response.text(); + let response = await postSDPOffer( + `${endpoint}`, + ofr.sdp, + bearerToken, + agent, + ); + let text = new TextDecoder().decode(response.data); + if (response.success) { if ((peerConnection.connectionState as string) === "closed") { return; } await peerConnection.setRemoteDescription( - new RTCSessionDescription({ type: "answer", sdp: answerSDP }), - ); - return response.headers.get("Location"); - } else if (response.status === 405) { - console.log( - "Remember to update the URL passed into the WHIP or WHEP client", + new RTCSessionDescription({ type: "answer", sdp: text }), ); + return "https://stream.place/example"; } else { - const errorMessage = await response.text(); - console.error(errorMessage); + console.error(text); } } catch (e) { console.error(`posting sdp offer failed: ${e}`); @@ -165,16 +176,26 @@ async function postSDPOffer( endpoint: string, data: string, bearerToken?: string, + agent?: StreamplaceAgent, ) { - return await fetch(endpoint, { - method: "POST", - mode: "cors", - headers: { - "content-type": "application/sdp", - ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), + if (!agent) { + throw new Error("No agent found"); + } + return await agent.place.stream.playback.whep(data, { + qp: { + rendition: "source", + streamer: agent.did!, }, - body: data, }); + // return await fetch(endpoint, { + // method: "POST", + // mode: "cors", + // headers: { + // "content-type": "application/sdp", + // ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), + // }, + // body: data, + // }); } /** diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 7efe3598..44570b9f 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -517,6 +517,77 @@ } } }, + "/xrpc/place.stream.playback.whep": { + "post": { + "summary": "Play a stream over WebRTC using WHEP.", + "operationId": "place.stream.playback.whep", + "tags": ["place.stream.playback"], + "responses": { + "200": { + "description": "Success", + "content": { + "*/*": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["error", "message"], + "properties": { + "error": { + "type": "string", + "oneOf": [ + { + "const": "Unauthorized" + } + ] + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "streamer", + "in": "query", + "required": true, + "description": "The DID of the streamer to play.", + "schema": { + "type": "string", + "description": "The DID of the streamer to play." + } + }, + { + "name": "rendition", + "in": "query", + "required": true, + "description": "The rendition of the stream to play.", + "schema": { + "type": "string", + "description": "The rendition of the stream to play." + } + } + ], + "requestBody": { + "required": true, + "content": { + "*/*": { + "schema": {} + } + } + } + } + }, "/xrpc/place.stream.multistream.createTarget": { "post": { "summary": "Create a new target for rebroadcasting a Streamplace stream.", diff --git a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-whep.md b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-whep.md new file mode 100644 index 00000000..519ac959 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-whep.md @@ -0,0 +1,82 @@ +--- +title: place.stream.playback.whep +description: Reference for the place.stream.playback.whep lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + +
+ +### `main` + +**Type:** `procedure` + +Play a stream over WebRTC using WHEP. + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| ----------- | -------- | ----- | ------------------------------------ | ----------- | +| `streamer` | `string` | ✅ | The DID of the streamer to play. | | +| `rendition` | `string` | ✅ | The rendition of the stream to play. | | + +**Input:** + +- **Encoding:** `*/*` +- **Schema:** + +_Schema not defined._ +**Output:** + +- **Encoding:** `*/*` +- **Schema:** + +_Schema not defined._ +**Possible Errors:** + +- `Unauthorized`: This user may not play this stream. + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.playback.whep", + "defs": { + "main": { + "type": "procedure", + "description": "Play a stream over WebRTC using WHEP.", + "parameters": { + "type": "params", + "required": ["streamer", "rendition"], + "properties": { + "streamer": { + "type": "string", + "description": "The DID of the streamer to play." + }, + "rendition": { + "type": "string", + "description": "The rendition of the stream to play." + } + } + }, + "input": { + "encoding": "*/*" + }, + "output": { + "encoding": "*/*" + }, + "errors": [ + { + "name": "Unauthorized", + "description": "This user may not play this stream." + } + ] + } + } +} +``` diff --git a/lexicons/place/stream/playback/whep.json b/lexicons/place/stream/playback/whep.json new file mode 100644 index 00000000..6415c8f8 --- /dev/null +++ b/lexicons/place/stream/playback/whep.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "place.stream.playback.whep", + "defs": { + "main": { + "type": "procedure", + "description": "Play a stream over WebRTC using WHEP.", + "parameters": { + "type": "params", + "required": ["streamer", "rendition"], + "properties": { + "streamer": { + "type": "string", + "description": "The DID of the streamer to play." + }, + "rendition": { + "type": "string", + "description": "The rendition of the stream to play." + } + } + }, + "input": { + "encoding": "*/*" + }, + "output": { + "encoding": "*/*" + }, + "errors": [ + { + "name": "Unauthorized", + "description": "This user may not play this stream." + } + ] + } + } +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 8c345030..ddf2d3ee 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -155,7 +155,7 @@ func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { Recorder: metrics.NewRecorder(metrics.Config{}), }) var xrpc http.Handler - xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB) + xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB, a.MediaManager) if err != nil { return nil, err } diff --git a/pkg/api/playback.go b/pkg/api/playback.go index 95a30c10..f76959a4 100644 --- a/pkg/api/playback.go +++ b/pkg/api/playback.go @@ -28,7 +28,7 @@ func (a *StreamplaceAPI) NormalizeUser(ctx context.Context, user string) (string return user, nil } // only other allowed case is a bluesky handle - repo, err := a.ATSync.SyncBlueskyRepoCached(ctx, user, a.Model) + repo, err := a.ATSync.SyncBlueskyRepoCached(ctx, user) if err != nil { return "", err } diff --git a/pkg/atproto/atproto.go b/pkg/atproto/atproto.go index 7f31af9e..e4598d32 100644 --- a/pkg/atproto/atproto.go +++ b/pkg/atproto/atproto.go @@ -22,10 +22,10 @@ import ( var SyncGetRepo = comatproto.SyncGetRepo -func (atsync *ATProtoSynchronizer) SyncBlueskyRepoCached(ctx context.Context, handle string, mod model.Model) (*model.Repo, error) { +func (atsync *ATProtoSynchronizer) SyncBlueskyRepoCached(ctx context.Context, handle string) (*model.Repo, error) { ctx, span := otel.Tracer("signer").Start(ctx, "SyncBlueskyRepoCached") defer span.End() - repo, err := mod.GetRepoByHandleOrDID(handle) + repo, err := atsync.Model.GetRepoByHandleOrDID(handle) if err != nil { return nil, fmt.Errorf("failed to get repo for %s: %w", handle, err) } @@ -33,7 +33,7 @@ func (atsync *ATProtoSynchronizer) SyncBlueskyRepoCached(ctx context.Context, ha return repo, nil } - return atsync.SyncBlueskyRepo(ctx, handle, mod) + return atsync.SyncBlueskyRepo(ctx, handle, atsync.Model) } type mstNode struct { diff --git a/pkg/atproto/migrate.go b/pkg/atproto/migrate.go index 5c283df8..cff9a165 100644 --- a/pkg/atproto/migrate.go +++ b/pkg/atproto/migrate.go @@ -60,7 +60,7 @@ func (atsync *ATProtoSynchronizer) Migrate(ctx context.Context) error { currentDID := did g.Go(func() error { log.Debug(ctx, "syncing repo", "did", currentDID, "progress", currentIndex+1, "total", len(allDIDs)) - _, err := atsync.SyncBlueskyRepoCached(ctx, currentDID, atsync.Model) + _, err := atsync.SyncBlueskyRepoCached(ctx, currentDID) if err != nil { log.Error(ctx, "failed to sync repo", "did", currentDID, "err", err) syncErrorMu.Lock() diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 465f7988..1ec2dabb 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -97,13 +97,13 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } case *streamplace.ChatMessage: - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } go func() { - _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer, atsync.Model) + _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer) if err != nil { log.Error(ctx, "failed to sync bluesky repo", "err", err) } @@ -178,7 +178,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } case *streamplace.ChatGate: - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } @@ -210,7 +210,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD go atsync.Bus.Publish(userDID, streamplaceGate) case *streamplace.ChatProfile: - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } @@ -225,7 +225,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } case *streamplace.ServerSettings: - _, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + _, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } @@ -253,7 +253,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } if livestream, ok := d["place.stream.livestream"]; ok { - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } @@ -292,7 +292,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD // log.Warn(ctx, "chat message detected", "uri", livestream.URI) // if this post is a reply to someone's livestream post // log.Warn(ctx, "chat message detected", "message", rec.Text) - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } @@ -483,11 +483,11 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } case *streamplace.BroadcastOrigin: - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync broadcast origin creator bluesky repo: %w", err) } - _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer, atsync.Model) + _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer) if err != nil { return fmt.Errorf("failed to sync broadcast origin streamer bluesky repo: %w", err) } @@ -508,7 +508,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD go atsync.Bus.Publish("", view) case *streamplace.MetadataConfiguration: - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } @@ -524,7 +524,7 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } case *streamplace.ModerationPermission: - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) if err != nil { return fmt.Errorf("failed to sync bluesky repo: %w", err) } diff --git a/pkg/bus/segchanman.go b/pkg/bus/segchanman.go index efd47558..c820f027 100644 --- a/pkg/bus/segchanman.go +++ b/pkg/bus/segchanman.go @@ -16,6 +16,7 @@ type Seg struct { Filepath string Data []byte PacketizedData *PacketizedSegment + Published bool } type PacketizedSegment struct { diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 431274da..d16abdae 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -208,11 +208,17 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment ss.bus.Publish(spseg.Creator, spseg) ss.Go(ctx, func() error { return ss.AddPlaybackSegment(ctx, spseg, "source", &bus.Seg{ - Filepath: notif.Segment.ID, - Data: notif.Data, + Filepath: notif.Segment.ID, + Data: notif.Data, + Published: notif.Metadata.Published, }) }) + // everything else is for published segments + if !notif.Metadata.Published { + return nil + } + if ss.cli.Thumbnail { ss.Go(ctx, func() error { return ss.Thumbnail(ctx, spseg.Creator, notif) @@ -721,9 +727,11 @@ func (ss *StreamSession) Transcode(ctx context.Context, spseg *streamplace.Segme } func (ss *StreamSession) AddPlaybackSegment(ctx context.Context, spseg *streamplace.Segment, rendition string, seg *bus.Seg) error { - ss.Go(ctx, func() error { - return ss.AddToHLS(ctx, spseg, rendition, seg.Data) - }) + if seg.Published { + ss.Go(ctx, func() error { + return ss.AddToHLS(ctx, spseg, rendition, seg.Data) + }) + } ss.Go(ctx, func() error { return ss.AddToWebRTC(ctx, spseg, rendition, seg) }) diff --git a/pkg/media/media.go b/pkg/media/media.go index 30e45ee8..82b428b0 100644 --- a/pkg/media/media.go +++ b/pkg/media/media.go @@ -197,24 +197,52 @@ type SegmentMetadata struct { DistributionPolicy *localdb.DistributionPolicy MetadataConfiguration *streamplace.MetadataConfiguration Livestream *streamplace.Livestream + Published bool } var ErrMissingMetadata = errors.New("missing segment metadata") var ErrInvalidMetadata = errors.New("invalid segment metadata") +var C2PAActionsV2Label = "c2pa.actions.v2" +var C2PAPublishedAction = "c2pa.published" func ParseSegmentAssertions(ctx context.Context, mani *c2patypes.Manifest) (*SegmentMetadata, error) { _, span := otel.Tracer("signer").Start(ctx, "ParseSegmentAssertions") defer span.End() var ass *c2patypes.ManifestAssertion + isPublished := false for _, a := range mani.Assertions { if a.Label == StreamplaceMetadata { ass = &a - break + continue } if a.Label == "place.stream.metadata" { // backwards compatibility for old manifests ass = &a - break + continue + } + if a.Label == C2PAActionsV2Label { + data, ok := a.Data.(map[string]any) + if !ok { + return nil, ErrInvalidMetadata + } + actions, ok := data["actions"].([]any) + if !ok { + return nil, ErrInvalidMetadata + } + for _, action := range actions { + actionMap, ok := action.(map[string]any) + if !ok { + return nil, ErrInvalidMetadata + } + actionType, ok := actionMap["action"].(string) + if !ok { + return nil, ErrInvalidMetadata + } + if actionType == C2PAPublishedAction { + isPublished = true + break + } + } } } if ass == nil { @@ -268,6 +296,7 @@ func ParseSegmentAssertions(ctx context.Context, mani *c2patypes.Manifest) (*Seg DistributionPolicy: distributionPolicy, MetadataConfiguration: metadataConfiguration, Livestream: livestream, + Published: isPublished, } return &out, nil } diff --git a/pkg/media/validate.go b/pkg/media/validate.go index 6d15447a..f28bae31 100644 --- a/pkg/media/validate.go +++ b/pkg/media/validate.go @@ -75,7 +75,7 @@ func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader, local signingKeyDID = meta.Creator repoDID = meta.Creator } else { - repo, err := mm.atsync.SyncBlueskyRepoCached(ctx, meta.Creator, mm.model) + repo, err := mm.atsync.SyncBlueskyRepoCached(ctx, meta.Creator) if err != nil { return err } diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go new file mode 100644 index 00000000..787cbfa2 --- /dev/null +++ b/pkg/spxrpc/place_stream_playback.go @@ -0,0 +1,34 @@ +package spxrpc + +import ( + "bytes" + "context" + "io" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/pion/webrtc/v4" +) + +func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, contentType string) (io.Reader, error) { + if streamer == "" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "streamer is required") + } + if rendition == "" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "rendition is required") + } + repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) + if err != nil { + return nil, err + } + body, err := io.ReadAll(r) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, "error reading body", err) + } + offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} + answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error playing back", err) + } + return bytes.NewReader([]byte(answer.SDP)), nil +} diff --git a/pkg/spxrpc/spxrpc.go b/pkg/spxrpc/spxrpc.go index 68d0cff9..cf00df19 100644 --- a/pkg/spxrpc/spxrpc.go +++ b/pkg/spxrpc/spxrpc.go @@ -20,6 +20,7 @@ import ( "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/localdb" "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/media" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/statedb" ) @@ -35,9 +36,10 @@ type Server struct { bus *bus.Bus op *oatproxy.OATProxy localDB localdb.LocalDB + mm *media.MediaManager } -func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB) (*Server, error) { +func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB, mm *media.MediaManager) (*Server, error) { e := echo.New() s := &Server{ e: e, @@ -50,6 +52,7 @@ func NewServer(ctx context.Context, cli *config.CLI, model model.Model, stateful bus: bus, op: op, localDB: ldb, + mm: mm, } e.Use(s.ErrorHandlingMiddleware()) e.Use(s.ContextPreservingMiddleware()) diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 026f74c4..de19be2d 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -299,6 +299,7 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget) e.GET("/xrpc/place.stream.multistream.listTargets", s.HandlePlaceStreamMultistreamListTargets) e.POST("/xrpc/place.stream.multistream.putTarget", s.HandlePlaceStreamMultistreamPutTarget) + e.POST("/xrpc/place.stream.playback.whep", s.HandlePlaceStreamPlaybackWhep) e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) @@ -692,6 +693,23 @@ func (s *Server) HandlePlaceStreamMultistreamPutTarget(c echo.Context) error { return c.JSON(200, out) } +func (s *Server) HandlePlaceStreamPlaybackWhep(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamPlaybackWhep") + defer span.End() + rendition := c.QueryParam("rendition") + streamer := c.QueryParam("streamer") + body := c.Request().Body + contentType := c.Request().Header.Get("Content-Type") + var out io.Reader + var handleErr error + // func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context,rendition string,streamer string,r io.Reader,contentType string) (io.Reader, error) + out, handleErr = s.handlePlaceStreamPlaybackWhep(ctx, rendition, streamer, body, contentType) + if handleErr != nil { + return handleErr + } + return c.Stream(200, "application/octet-stream", out) +} + func (s *Server) HandlePlaceStreamServerCreateWebhook(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamServerCreateWebhook") defer span.End() diff --git a/pkg/streamplace/playbackwhep.go b/pkg/streamplace/playbackwhep.go new file mode 100644 index 00000000..86285df6 --- /dev/null +++ b/pkg/streamplace/playbackwhep.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.playback.whep + +package streamplace + +import ( + "bytes" + "context" + "io" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// PlaybackWhep calls the XRPC method "place.stream.playback.whep". +// +// rendition: The rendition of the stream to play. +// streamer: The DID of the streamer to play. +func PlaybackWhep(ctx context.Context, c lexutil.LexClient, input io.Reader, rendition string, streamer string) ([]byte, error) { + buf := new(bytes.Buffer) + + params := map[string]interface{}{} + params["rendition"] = rendition + params["streamer"] = streamer + if err := c.LexDo(ctx, lexutil.Procedure, "*/*", "place.stream.playback.whep", params, input, buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} -- 2.51.2 From 8e2f4eff457c4dbb7a44196c03d7c0cbe6e4b8bf Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:37:16 -0600 Subject: [PATCH 23/49] fix: remount volume slider on sidebar toggle to fix drag offset --- js/app/components/mobile/desktop-ui/bottom-controls.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx index 2f33bd0d..1983f0a9 100644 --- a/js/app/components/mobile/desktop-ui/bottom-controls.tsx +++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx @@ -17,6 +17,7 @@ import { PictureInPicture2, } from "lucide-react-native"; import { Platform, Pressable } from "react-native"; +import { useIsSidebarCollapsed } from "store/hooks"; import { VolumeSlider } from "./volume-slider"; import { Mu } from "./mu"; @@ -48,6 +49,7 @@ export function BottomControlBar({ const danmuUnlocked = useDanmuUnlocked(); const danmuEnabled = useDanmuEnabled(); const setDanmuEnabled = useSetDanmuEnabled(); + const sidebarCollapsed = useIsSidebarCollapsed(); return ( - + -- 2.51.2 From ebea304012b5964d830b4a0c2d56ae2bb1af8451 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 20 Feb 2026 16:08:42 -0800 Subject: [PATCH 24/49] player: unbreak useWebrtc --- .../components/mobile-player/use-webrtc.tsx | 111 +++++++++++++++--- .../src/components/mobile-player/video.tsx | 3 +- .../src/player-store/player-state.tsx | 2 +- pkg/spxrpc/place_stream_playback.go | 2 +- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/js/components/src/components/mobile-player/use-webrtc.tsx b/js/components/src/components/mobile-player/use-webrtc.tsx index af7495ab..787fa97c 100644 --- a/js/components/src/components/mobile-player/use-webrtc.tsx +++ b/js/components/src/components/mobile-player/use-webrtc.tsx @@ -1,16 +1,22 @@ import { useEffect, useRef, useState } from "react"; import * as sdpTransform from "sdp-transform"; +import { text } from "stream/consumers"; import { StreamplaceAgent } from "streamplace"; -import { PlayerStatus, usePDSAgent, usePlayerStore, useStreamKey } from "../.."; +import { + PlayerStatus, + usePlayerStore, + usePossiblyUnauthedPDSAgent, + useStreamKey, +} from "../.."; import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; export default function useWebRTC( - endpoint: string, + streamer: string, ): [MediaStream | null, boolean] { const [mediaStream, setMediaStream] = useState(null); const [stuck, setStuck] = useState(false); const setStatus = usePlayerStore((x) => x.setStatus); - let agent = usePDSAgent(); + let agent = usePossiblyUnauthedPDSAgent(); const lastChange = useRef(0); @@ -51,7 +57,7 @@ export default function useWebRTC( peerConnection.addEventListener("negotiationneeded", () => { negotiateConnectionWithClientOffer( peerConnection, - endpoint, + streamer, undefined, agent, ); @@ -92,7 +98,7 @@ export default function useWebRTC( clearInterval(handle); peerConnection.close(); }; - }, [endpoint, agent]); + }, [streamer, agent]); return [mediaStream, stuck]; } @@ -110,7 +116,7 @@ export default function useWebRTC( */ export async function negotiateConnectionWithClientOffer( peerConnection: RTCPeerConnection, - endpoint: string, + streamer: string, bearerToken?: string, agent?: StreamplaceAgent, ) { @@ -145,12 +151,7 @@ export async function negotiateConnectionWithClientOffer( * This specifies how the client should communicate, * and what kind of media client and server have negotiated to exchange. */ - let response = await postSDPOffer( - `${endpoint}`, - ofr.sdp, - bearerToken, - agent, - ); + let response = await postSDPOffer(streamer, ofr.sdp, bearerToken, agent); let text = new TextDecoder().decode(response.data); if (response.success) { if ((peerConnection.connectionState as string) === "closed") { @@ -172,8 +173,69 @@ export async function negotiateConnectionWithClientOffer( } } -async function postSDPOffer( +export async function negotiateIngestConnectionWithClientOffer( + peerConnection: RTCPeerConnection, endpoint: string, + bearerToken: string, +) { + /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ + const offer = await peerConnection.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }); + if (!offer.sdp) { + throw Error("no SDP in offer"); + } + + const newSDP = forceStereoAudio(offer.sdp); + + offer.sdp = newSDP; + /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */ + await peerConnection.setLocalDescription(offer); + + /** Wait for ICE gathering to complete */ + let ofr = await waitToCompleteICEGathering(peerConnection); + if (!ofr) { + throw Error("failed to gather ICE candidates for offer"); + } + + /** + * As long as the connection is open, attempt to... + */ + while (peerConnection.connectionState !== "closed") { + try { + /** + * This response contains the server's SDP offer. + * This specifies how the client should communicate, + * and what kind of media client and server have negotiated to exchange. + */ + let response = await postSDPIngestOffer(endpoint, ofr.sdp, bearerToken); + + if (response.status === 201) { + if ((peerConnection.connectionState as string) === "closed") { + return; + } + await peerConnection.setRemoteDescription( + new RTCSessionDescription({ + type: "answer", + sdp: await response.text(), + }), + ); + return "https://stream.place/example"; + } else { + console.error(text); + } + } catch (e) { + console.error(`posting sdp offer failed: ${e}`); + } + + /** Limit reconnection attempts to at-most once every 5 seconds */ + await new Promise((r) => setTimeout(r, 5000)); + } +} + +async function postSDPOffer( + streamer: string, data: string, bearerToken?: string, agent?: StreamplaceAgent, @@ -184,7 +246,7 @@ async function postSDPOffer( return await agent.place.stream.playback.whep(data, { qp: { rendition: "source", - streamer: agent.did!, + streamer: streamer, }, }); // return await fetch(endpoint, { @@ -198,6 +260,22 @@ async function postSDPOffer( // }); } +async function postSDPIngestOffer( + endpoint: string, + data: string, + bearerToken: string, +) { + return await fetch(endpoint, { + method: "POST", + mode: "cors", + headers: { + "content-type": "application/sdp", + ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), + }, + body: data, + }); +} + /** * Receives an RTCPeerConnection and waits until * the connection is initialized or a timeout passes. @@ -275,7 +353,10 @@ export function useWebRTCIngest({ } }); peerConnection.addEventListener("negotiationneeded", (ev) => { - negotiateConnectionWithClientOffer( + if (!storedKey?.streamKey?.privateKey) { + throw new Error("no private key found"); + } + negotiateIngestConnectionWithClientOffer( peerConnection, endpoint, storedKey.streamKey?.privateKey, diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index d7e049e4..ace0d103 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -442,11 +442,12 @@ export function WebRTCPlayerInner({ const status = usePlayerStore((x) => x.status); const setStatus = usePlayerStore((x) => x.setStatus); + const src = usePlayerStore((x) => x.src); const playerEvent = usePlayerStore((x) => x.playerEvent); const spurl = useStreamplaceStore((x) => x.url); - const [mediaStream, stuck] = useWebRTC(url); + const [mediaStream, stuck] = useWebRTC(src); useEffect(() => { if (stuck) { diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx index b7f76e6f..ac885209 100644 --- a/js/components/src/player-store/player-state.tsx +++ b/js/components/src/player-store/player-state.tsx @@ -32,7 +32,7 @@ export interface PlayerState { protocol: PlayerProtocol; setProtocol: (protocol: PlayerProtocol) => void; - /** Source */ + /** Source (streamer did) */ src: string; /** Function to set the source URL */ diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go index 787cbfa2..e092558d 100644 --- a/pkg/spxrpc/place_stream_playback.go +++ b/pkg/spxrpc/place_stream_playback.go @@ -10,7 +10,7 @@ import ( "github.com/pion/webrtc/v4" ) -func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, contentType string) (io.Reader, error) { +func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { if streamer == "" { return nil, echo.NewHTTPError(http.StatusBadRequest, "streamer is required") } -- 2.51.2 From 40c398d3fc0d1e154ce6f5f0284ddba9f53476b2 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:12:13 -0600 Subject: [PATCH 25/49] change wording a tad --- js/components/src/components/chat/user-profile-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/components/src/components/chat/user-profile-card.tsx b/js/components/src/components/chat/user-profile-card.tsx index 1a052cea..df7facb6 100644 --- a/js/components/src/components/chat/user-profile-card.tsx +++ b/js/components/src/components/chat/user-profile-card.tsx @@ -34,7 +34,7 @@ const BADGE_META: Record = { "place.stream.badge.defs#mod": { label: "Moderator", description: "This user is a moderator.", - issuedBy: "{issuer} p.p. {streamer}", + issuedBy: "{issuer} for {streamer}", }, "place.stream.badge.defs#streamer": { label: "Streamer", -- 2.51.2 From 6639876a828f116efbb08ef6cf34d319b116e4a1 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:58:25 -0600 Subject: [PATCH 26/49] remove issuance and badge picker lexicon --- .../badge/place-stream-badge-display.md | 90 --- .../badge/place-stream-badge-issuance.md | 63 --- lexicons/place/stream/badge/display.json | 44 -- lexicons/place/stream/badge/issuance.json | 30 - pkg/gen/gen.go | 3 - pkg/streamplace/badgedisplay.go | 28 - pkg/streamplace/badgeissuance.go | 22 - pkg/streamplace/cbor_gen.go | 527 ------------------ 8 files changed, 807 deletions(-) delete mode 100644 js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md delete mode 100644 js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md delete mode 100644 lexicons/place/stream/badge/display.json delete mode 100644 lexicons/place/stream/badge/issuance.json delete mode 100644 pkg/streamplace/badgedisplay.go delete mode 100644 pkg/streamplace/badgeissuance.go diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md deleted file mode 100644 index 718ceb62..00000000 --- a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: place.stream.badge.display -description: Reference for the place.stream.badge.display lexicon ---- - -**Lexicon Version:** 1 - -## Definitions - - - -### `main` - -**Type:** `record` - -Record issuing a badge to a user. - -**Record Properties:** - -| Name | Type | Req'd | Description | Constraints | -| -------- | --------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | -| `badges` | Array of [`#badgeSelection`](#badgeselection) | ✅ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. | Max Items: 3 | - ---- - - - -### `badgeSelection` - -**Type:** `object` - -A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. - -**Properties:** - -| Name | Type | Req'd | Description | Constraints | -| ----------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#vip` | -| `issuance` | `string` | ❌ | URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. | Format: `at-uri` | - ---- - -## Lexicon Source - -```json -{ - "lexicon": 1, - "id": "place.stream.badge.display", - "defs": { - "main": { - "type": "record", - "description": "Record issuing a badge to a user.", - "record": { - "type": "object", - "required": ["badges"], - "properties": { - "badges": { - "type": "array", - "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", - "maxLength": 3, - "items": { - "type": "ref", - "ref": "#badgeSelection" - } - } - } - } - }, - "badgeSelection": { - "type": "object", - "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", - "required": ["badgeType"], - "properties": { - "badgeType": { - "type": "string", - "knownValues": [ - "place.stream.badge.defs#mod", - "place.stream.badge.defs#vip" - ] - }, - "issuance": { - "type": "string", - "format": "at-uri", - "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." - } - } - } - } -} -``` diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md deleted file mode 100644 index df4ee9a0..00000000 --- a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: place.stream.badge.issuance -description: Reference for the place.stream.badge.issuance lexicon ---- - -**Lexicon Version:** 1 - -## Definitions - - - -### `main` - -**Type:** `record` - -Record issuing a badge to a user. - -**Record Key:** `tid` - -**Record Properties:** - -| Name | Type | Req'd | Description | Constraints | -| ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------- | -| `badgeType` | `string` | ✅ | | Known Values: `place.stream.badge.defs#vip` | -| `recipient` | `string` | ✅ | DID of the badge recipient. | Format: `did` | -| `signature` | `string` | ✅ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | - ---- - -## Lexicon Source - -```json -{ - "lexicon": 1, - "id": "place.stream.badge.issuance", - "defs": { - "main": { - "type": "record", - "key": "tid", - "description": "Record issuing a badge to a user.", - "record": { - "type": "object", - "required": ["badgeType", "recipient", "signature"], - "properties": { - "badgeType": { - "type": "string", - "knownValues": ["place.stream.badge.defs#vip"] - }, - "recipient": { - "type": "string", - "format": "did", - "description": "DID of the badge recipient." - }, - "signature": { - "type": "string", - "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." - } - } - } - } - } -} -``` diff --git a/lexicons/place/stream/badge/display.json b/lexicons/place/stream/badge/display.json deleted file mode 100644 index 2f34939b..00000000 --- a/lexicons/place/stream/badge/display.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "lexicon": 1, - "id": "place.stream.badge.display", - "defs": { - "main": { - "type": "record", - "description": "Record issuing a badge to a user.", - "record": { - "type": "object", - "required": ["badges"], - "properties": { - "badges": { - "type": "array", - "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", - "maxLength": 3, - "items": { - "type": "ref", - "ref": "#badgeSelection" - } - } - } - } - }, - "badgeSelection": { - "type": "object", - "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", - "required": ["badgeType"], - "properties": { - "badgeType": { - "type": "string", - "knownValues": [ - "place.stream.badge.defs#mod", - "place.stream.badge.defs#vip" - ] - }, - "issuance": { - "type": "string", - "format": "at-uri", - "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." - } - } - } - } -} diff --git a/lexicons/place/stream/badge/issuance.json b/lexicons/place/stream/badge/issuance.json deleted file mode 100644 index 6566d135..00000000 --- a/lexicons/place/stream/badge/issuance.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "lexicon": 1, - "id": "place.stream.badge.issuance", - "defs": { - "main": { - "type": "record", - "key": "tid", - "description": "Record issuing a badge to a user.", - "record": { - "type": "object", - "required": ["badgeType", "recipient", "signature"], - "properties": { - "badgeType": { - "type": "string", - "knownValues": ["place.stream.badge.defs#vip"] - }, - "recipient": { - "type": "string", - "format": "did", - "description": "DID of the badge recipient." - }, - "signature": { - "type": "string", - "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." - } - } - } - } - } -} diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go index 0ab88e75..6d8fbbf8 100644 --- a/pkg/gen/gen.go +++ b/pkg/gen/gen.go @@ -36,9 +36,6 @@ func main() { streamplace.ModerationPermission{}, streamplace.LiveTeleport{}, streamplace.LiveRecommendations{}, - streamplace.BadgeIssuance{}, - streamplace.BadgeDisplay{}, - streamplace.BadgeDisplay_BadgeSelection{}, ); err != nil { panic(err) } diff --git a/pkg/streamplace/badgedisplay.go b/pkg/streamplace/badgedisplay.go deleted file mode 100644 index a0c43b6b..00000000 --- a/pkg/streamplace/badgedisplay.go +++ /dev/null @@ -1,28 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -// Lexicon schema: place.stream.badge.display - -package streamplace - -import ( - lexutil "github.com/bluesky-social/indigo/lex/util" -) - -func init() { - lexutil.RegisterType("place.stream.badge.display", &BadgeDisplay{}) -} - -type BadgeDisplay struct { - LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.display"` - // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. - Badges []*BadgeDisplay_BadgeSelection `json:"badges" cborgen:"badges"` -} - -// BadgeDisplay_BadgeSelection is a "badgeSelection" in the place.stream.badge.display schema. -// -// A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. -type BadgeDisplay_BadgeSelection struct { - BadgeType string `json:"badgeType" cborgen:"badgeType"` - // issuance: URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. - Issuance *string `json:"issuance,omitempty" cborgen:"issuance,omitempty"` -} diff --git a/pkg/streamplace/badgeissuance.go b/pkg/streamplace/badgeissuance.go deleted file mode 100644 index 314ad25f..00000000 --- a/pkg/streamplace/badgeissuance.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -// Lexicon schema: place.stream.badge.issuance - -package streamplace - -import ( - lexutil "github.com/bluesky-social/indigo/lex/util" -) - -func init() { - lexutil.RegisterType("place.stream.badge.issuance", &BadgeIssuance{}) -} - -type BadgeIssuance struct { - LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.issuance"` - BadgeType string `json:"badgeType" cborgen:"badgeType"` - // recipient: DID of the badge recipient. - Recipient string `json:"recipient" cborgen:"recipient"` - // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). - Signature string `json:"signature" cborgen:"signature"` -} diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index c2648aec..3bc2fee9 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -5966,530 +5966,3 @@ func (t *LiveRecommendations) UnmarshalCBOR(r io.Reader) (err error) { return nil } -func (t *BadgeIssuance) MarshalCBOR(w io.Writer) error { - if t == nil { - _, err := w.Write(cbg.CborNull) - return err - } - - cw := cbg.NewCborWriter(w) - - if _, err := cw.Write([]byte{164}); err != nil { - return err - } - - // t.LexiconTypeID (string) (string) - if len("$type") > 1000000 { - return xerrors.Errorf("Value in field \"$type\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { - return err - } - if _, err := cw.WriteString(string("$type")); err != nil { - return err - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.issuance"))); err != nil { - return err - } - if _, err := cw.WriteString(string("place.stream.badge.issuance")); err != nil { - return err - } - - // t.BadgeType (string) (string) - if len("badgeType") > 1000000 { - return xerrors.Errorf("Value in field \"badgeType\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { - return err - } - if _, err := cw.WriteString(string("badgeType")); err != nil { - return err - } - - if len(t.BadgeType) > 1000000 { - return xerrors.Errorf("Value in field t.BadgeType was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.BadgeType)); err != nil { - return err - } - - // t.Recipient (string) (string) - if len("recipient") > 1000000 { - return xerrors.Errorf("Value in field \"recipient\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("recipient"))); err != nil { - return err - } - if _, err := cw.WriteString(string("recipient")); err != nil { - return err - } - - if len(t.Recipient) > 1000000 { - return xerrors.Errorf("Value in field t.Recipient was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Recipient))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.Recipient)); err != nil { - return err - } - - // t.Signature (string) (string) - if len("signature") > 1000000 { - return xerrors.Errorf("Value in field \"signature\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("signature"))); err != nil { - return err - } - if _, err := cw.WriteString(string("signature")); err != nil { - return err - } - - if len(t.Signature) > 1000000 { - return xerrors.Errorf("Value in field t.Signature was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Signature))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.Signature)); err != nil { - return err - } - return nil -} - -func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { - *t = BadgeIssuance{} - - cr := cbg.NewCborReader(r) - - maj, extra, err := cr.ReadHeader() - if err != nil { - return err - } - defer func() { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - }() - - if maj != cbg.MajMap { - return fmt.Errorf("cbor input should be of type map") - } - - if extra > cbg.MaxLength { - return fmt.Errorf("BadgeIssuance: map struct too large (%d)", extra) - } - - n := extra - - nameBuf := make([]byte, 9) - for i := uint64(0); i < n; i++ { - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) - if err != nil { - return err - } - - if !ok { - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { - return err - } - continue - } - - switch string(nameBuf[:nameLen]) { - // t.LexiconTypeID (string) (string) - case "$type": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.LexiconTypeID = string(sval) - } - // t.BadgeType (string) (string) - case "badgeType": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.BadgeType = string(sval) - } - // t.Recipient (string) (string) - case "recipient": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.Recipient = string(sval) - } - // t.Signature (string) (string) - case "signature": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.Signature = string(sval) - } - - default: - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { - return err - } - } - } - - return nil -} -func (t *BadgeDisplay) MarshalCBOR(w io.Writer) error { - if t == nil { - _, err := w.Write(cbg.CborNull) - return err - } - - cw := cbg.NewCborWriter(w) - - if _, err := cw.Write([]byte{162}); err != nil { - return err - } - - // t.LexiconTypeID (string) (string) - if len("$type") > 1000000 { - return xerrors.Errorf("Value in field \"$type\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { - return err - } - if _, err := cw.WriteString(string("$type")); err != nil { - return err - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.display"))); err != nil { - return err - } - if _, err := cw.WriteString(string("place.stream.badge.display")); err != nil { - return err - } - - // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) - if len("badges") > 1000000 { - return xerrors.Errorf("Value in field \"badges\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badges"))); err != nil { - return err - } - if _, err := cw.WriteString(string("badges")); err != nil { - return err - } - - if len(t.Badges) > 8192 { - return xerrors.Errorf("Slice value in field t.Badges was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Badges))); err != nil { - return err - } - for _, v := range t.Badges { - if err := v.MarshalCBOR(cw); err != nil { - return err - } - - } - return nil -} - -func (t *BadgeDisplay) UnmarshalCBOR(r io.Reader) (err error) { - *t = BadgeDisplay{} - - cr := cbg.NewCborReader(r) - - maj, extra, err := cr.ReadHeader() - if err != nil { - return err - } - defer func() { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - }() - - if maj != cbg.MajMap { - return fmt.Errorf("cbor input should be of type map") - } - - if extra > cbg.MaxLength { - return fmt.Errorf("BadgeDisplay: map struct too large (%d)", extra) - } - - n := extra - - nameBuf := make([]byte, 6) - for i := uint64(0); i < n; i++ { - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) - if err != nil { - return err - } - - if !ok { - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { - return err - } - continue - } - - switch string(nameBuf[:nameLen]) { - // t.LexiconTypeID (string) (string) - case "$type": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.LexiconTypeID = string(sval) - } - // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) - case "badges": - - maj, extra, err = cr.ReadHeader() - if err != nil { - return err - } - - if extra > 8192 { - return fmt.Errorf("t.Badges: array too large (%d)", extra) - } - - if maj != cbg.MajArray { - return fmt.Errorf("expected cbor array") - } - - if extra > 0 { - t.Badges = make([]*BadgeDisplay_BadgeSelection, extra) - } - - for i := 0; i < int(extra); i++ { - { - var maj byte - var extra uint64 - var err error - _ = maj - _ = extra - _ = err - - { - - b, err := cr.ReadByte() - if err != nil { - return err - } - if b != cbg.CborNull[0] { - if err := cr.UnreadByte(); err != nil { - return err - } - t.Badges[i] = new(BadgeDisplay_BadgeSelection) - if err := t.Badges[i].UnmarshalCBOR(cr); err != nil { - return xerrors.Errorf("unmarshaling t.Badges[i] pointer: %w", err) - } - } - - } - - } - } - - default: - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { - return err - } - } - } - - return nil -} -func (t *BadgeDisplay_BadgeSelection) MarshalCBOR(w io.Writer) error { - if t == nil { - _, err := w.Write(cbg.CborNull) - return err - } - - cw := cbg.NewCborWriter(w) - fieldCount := 2 - - if t.Issuance == nil { - fieldCount-- - } - - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { - return err - } - - // t.Issuance (string) (string) - if t.Issuance != nil { - - if len("issuance") > 1000000 { - return xerrors.Errorf("Value in field \"issuance\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issuance"))); err != nil { - return err - } - if _, err := cw.WriteString(string("issuance")); err != nil { - return err - } - - if t.Issuance == nil { - if _, err := cw.Write(cbg.CborNull); err != nil { - return err - } - } else { - if len(*t.Issuance) > 1000000 { - return xerrors.Errorf("Value in field t.Issuance was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Issuance))); err != nil { - return err - } - if _, err := cw.WriteString(string(*t.Issuance)); err != nil { - return err - } - } - } - - // t.BadgeType (string) (string) - if len("badgeType") > 1000000 { - return xerrors.Errorf("Value in field \"badgeType\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { - return err - } - if _, err := cw.WriteString(string("badgeType")); err != nil { - return err - } - - if len(t.BadgeType) > 1000000 { - return xerrors.Errorf("Value in field t.BadgeType was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.BadgeType)); err != nil { - return err - } - return nil -} - -func (t *BadgeDisplay_BadgeSelection) UnmarshalCBOR(r io.Reader) (err error) { - *t = BadgeDisplay_BadgeSelection{} - - cr := cbg.NewCborReader(r) - - maj, extra, err := cr.ReadHeader() - if err != nil { - return err - } - defer func() { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - }() - - if maj != cbg.MajMap { - return fmt.Errorf("cbor input should be of type map") - } - - if extra > cbg.MaxLength { - return fmt.Errorf("BadgeDisplay_BadgeSelection: map struct too large (%d)", extra) - } - - n := extra - - nameBuf := make([]byte, 9) - for i := uint64(0); i < n; i++ { - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) - if err != nil { - return err - } - - if !ok { - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { - return err - } - continue - } - - switch string(nameBuf[:nameLen]) { - // t.Issuance (string) (string) - case "issuance": - - { - b, err := cr.ReadByte() - if err != nil { - return err - } - if b != cbg.CborNull[0] { - if err := cr.UnreadByte(); err != nil { - return err - } - - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.Issuance = (*string)(&sval) - } - } - // t.BadgeType (string) (string) - case "badgeType": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.BadgeType = string(sval) - } - - default: - // Field doesn't exist on this type, so ignore it - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { - return err - } - } - } - - return nil -} -- 2.51.2 From 77d2e755ed6fb1ee46a882ace26d2d0830c166a3 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:15:25 -0600 Subject: [PATCH 27/49] add 2x variants --- js/components/assets/badges/live_2x.png | Bin 0 -> 447 bytes js/components/assets/badges/mod_2x.png | Bin 0 -> 454 bytes js/components/assets/badges/vip_2x.png | Bin 0 -> 448 bytes js/components/src/components/chat/badge.tsx | 6 +++--- 4 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 js/components/assets/badges/live_2x.png create mode 100644 js/components/assets/badges/mod_2x.png create mode 100644 js/components/assets/badges/vip_2x.png diff --git a/js/components/assets/badges/live_2x.png b/js/components/assets/badges/live_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2654562ce0f86ae818d6bdfd100bef97c0150f6b GIT binary patch literal 447 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEACL211zuIX0<yMJhDdxY8E z`OYVsMRb4N*Or+wsu{?}xd?w8P7{j)fBpT=>#e_;1r+XbRxMutlr@P- zp`rL~GsB{WD?EWr296JBD)Ch0#DDYs9e1SgW`k)oOF=v?-7;bKgtZoW>c9k>3_0U7s4MI-V1;$JY4v$RxUh{@Y zmj8Cs*u!w=`|q&VtM9W$*{-2=0^t(y^pCceziIrtMsZ7Y7%Y|Zt`}%u79Uv=cg6Z zj>U5vj?H_1XqXF`}57#J8lUHx3vIVCg!0Dm;Xy8r+H literal 0 HcmV?d00001 diff --git a/js/components/assets/badges/vip_2x.png b/js/components/assets/badges/vip_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a993b247793472f7b86429e31e7bc02e98e1b098 GIT binary patch literal 448 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEx4+2qmUf{@4?*JQM=e26Q&F-{^BfG`-QgiUcczqac*bJm;N)kr zUBy98M)xkbrMvddG`{s>%?iWfwnuZOOz085^rOb3VdnV#KJ#@Zs!(czMolsOmN(%oEU7#hFJ zI1>(cIoP1EoR>$N^6ib>_aTmGk@xaA#(ujmwqlWm`kzHDDK!(`-e~1KYMB_wdGd$* hwq$WJjdQh(@rngI_oe4pGB7YOc)I$ztaD0e0szNGzEl7J literal 0 HcmV?d00001 diff --git a/js/components/src/components/chat/badge.tsx b/js/components/src/components/chat/badge.tsx index 0d5ea030..102c7fbc 100644 --- a/js/components/src/components/chat/badge.tsx +++ b/js/components/src/components/chat/badge.tsx @@ -2,9 +2,9 @@ import { Image } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; export const BADGE_IMAGES: Record> = { - "place.stream.badge.defs#mod": require("../../../assets/badges/mod.png"), - "place.stream.badge.defs#streamer": require("../../../assets/badges/live.png"), - "place.stream.badge.defs#vip": require("../../../assets/badges/vip.png"), + "place.stream.badge.defs#mod": require("../../../assets/badges/mod_2x.png"), + "place.stream.badge.defs#streamer": require("../../../assets/badges/live_2x.png"), + "place.stream.badge.defs#vip": require("../../../assets/badges/vip_2x.png"), }; export const Badge = ({ -- 2.51.2 From 3d30843801332be68a2ac117425e9ac2be19711d Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 22 Feb 2026 13:36:36 -0800 Subject: [PATCH 28/49] live-dashboard: first pass at visual changes for pre-live --- .../live-dashboard/livestream-panel.tsx | 32 +++++++++++-- .../live-dashboard/stream-monitor.tsx | 47 ++++++++++++++----- .../src/livestream-store/livestream-store.tsx | 8 +++- pkg/api/playback.go | 2 +- pkg/media/webrtc_playback2.go | 6 ++- pkg/media/webrtc_playback2_test.go | 2 +- pkg/spxrpc/place_stream_playback.go | 9 +++- 7 files changed, 85 insertions(+), 21 deletions(-) diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index b945bdab..e7e35587 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -209,11 +209,12 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { const userIsLive = useLiveUser(); const captureFrame = useCaptureVideoFrame(); const profile = useUserProfile(); - const livestream = useLivestream(); + const livestream = useLivestream(true); const createStreamRecord = useCreateStreamRecord(); const updateStreamRecord = useUpdateStreamRecord(); const endLivestream = useEndLivestream(); const url = useUrl(); + const [endingLivestream, setEndingLivestream] = useState(false); const [title, setTitle] = useState(""); const [loading, setLoading] = useState(false); @@ -352,9 +353,23 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { const handleEndLivestream = useCallback(async () => { if (!livestream) return; - await endLivestream(livestream); + setEndingLivestream(true); + try { + await endLivestream(livestream); + } catch (error) { + console.error("Error ending livestream:", error); + toast.show("Error", "Failed to end livestream", { + duration: 3, + }); + } }, [livestream, endLivestream]); + useEffect(() => { + if (livestream && livestream.record.endedAt !== undefined) { + setEndingLivestream(false); + } + }, [livestream]); + const handleImageSelect = useCallback(() => { // Default web file picker behavior const input = document.createElement("input"); @@ -419,6 +434,9 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { } : {}; + const canEndLivestream = + livestream && livestream.record.endedAt === undefined; + return ( <> @@ -668,20 +686,24 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { diff --git a/js/app/components/live-dashboard/stream-monitor.tsx b/js/app/components/live-dashboard/stream-monitor.tsx index 3b977c4f..301963ec 100644 --- a/js/app/components/live-dashboard/stream-monitor.tsx +++ b/js/app/components/live-dashboard/stream-monitor.tsx @@ -33,7 +33,7 @@ export default function StreamMonitor({ const isUserLive = useLiveUser(); const profile = useLivestreamStore((x) => x.profile); const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); - const ls = useLivestream(); + let ls = useLivestream(); const segmentTiming = useSegmentTiming(); // Use hook data primarily, fallback to props @@ -88,6 +88,39 @@ export default function StreamMonitor({ return "red"; } }; + + const getStreamStatus = () => { + if (!isLive) return "OFFLINE"; + if (!ls) return "NOT LIVE"; + return "LIVE"; + }; + + const getStreamTitle = () => { + if (!ls) { + return ( + + Stream not live yet. Press "Announce Livestream" to start! + + ); + } + return ( + + {ls?.record.title || "Stream Title"} + + ); + }; + return ( - - - {ls?.record.title || "Stream Title"} - - + {getStreamTitle()} - {isLive ? "LIVE" : "OFFLINE"} + {getStreamStatus()} diff --git a/js/components/src/livestream-store/livestream-store.tsx b/js/components/src/livestream-store/livestream-store.tsx index 304d1be8..eff4e30d 100644 --- a/js/components/src/livestream-store/livestream-store.tsx +++ b/js/components/src/livestream-store/livestream-store.tsx @@ -62,7 +62,13 @@ export const useProfile = () => useLivestreamStore((x) => x.profile); export const useViewers = () => useLivestreamStore((x) => x.viewers); -export const useLivestream = () => useLivestreamStore((x) => x.livestream); +export const useLivestream = (includeEnded: boolean = false) => + useLivestreamStore((x) => { + const ls = x.livestream; + if (!ls) return null; + if (!includeEnded && ls.record.endedAt !== undefined) return null; + return ls; + }); export const useSegment = () => useLivestreamStore((x) => x.segment); diff --git a/pkg/api/playback.go b/pkg/api/playback.go index f76959a4..cdae6e22 100644 --- a/pkg/api/playback.go +++ b/pkg/api/playback.go @@ -56,7 +56,7 @@ func (a *StreamplaceAPI) HandleWebRTCPlayback(ctx context.Context) httprouter.Ha offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} var answer *webrtc.SessionDescription if a.CLI.NewWebRTCPlayback { - answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer) + answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer, "") } else { answer, err = a.MediaManager.WebRTCPlayback(ctx, user, rendition, &offer) } diff --git a/pkg/media/webrtc_playback2.go b/pkg/media/webrtc_playback2.go index 5ee005af..edb74cf1 100644 --- a/pkg/media/webrtc_playback2.go +++ b/pkg/media/webrtc_playback2.go @@ -14,7 +14,7 @@ import ( ) // This function remains in scope for the duration of a single users' playback -func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) { +func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription, viewer string) (*webrtc.SessionDescription, error) { uu, err := uuid.NewV7() if err != nil { return nil, err @@ -93,6 +93,10 @@ func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendit return case file := <-segChan.C: log.Debug(ctx, "got segment", "file", file.Filepath) + if !file.Published && viewer != user { + log.Warn(ctx, "segment is not published and viewer is not the user", "viewer", viewer, "user", user) + continue + } latency += file.PacketizedData.Duration packetQueue <- file.PacketizedData } diff --git a/pkg/media/webrtc_playback2_test.go b/pkg/media/webrtc_playback2_test.go index 3512db8a..53d11756 100644 --- a/pkg/media/webrtc_playback2_test.go +++ b/pkg/media/webrtc_playback2_test.go @@ -17,7 +17,7 @@ func TestWebRTCPlayback2(t *testing.T) { Type: webrtc.SDPTypeOffer, SDP: firefoxNoH264SDP, } - answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer) + answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer, "") require.ErrorContains(t, err, "RTPSender created with no codecs") require.Nil(t, answer) } diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go index e092558d..9cca974c 100644 --- a/pkg/spxrpc/place_stream_playback.go +++ b/pkg/spxrpc/place_stream_playback.go @@ -8,15 +8,22 @@ import ( "github.com/labstack/echo/v4" "github.com/pion/webrtc/v4" + "github.com/streamplace/oatproxy/pkg/oatproxy" ) func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { + if streamer == "" { return nil, echo.NewHTTPError(http.StatusBadRequest, "streamer is required") } if rendition == "" { return nil, echo.NewHTTPError(http.StatusBadRequest, "rendition is required") } + viewer := "" + session, _ := oatproxy.GetOAuthSession(ctx) + if session != nil { + viewer = session.DID + } repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) if err != nil { return nil, err @@ -26,7 +33,7 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st return nil, echo.NewHTTPError(http.StatusBadRequest, "error reading body", err) } offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} - answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer) + answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer, viewer) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "error playing back", err) } -- 2.51.2 From 5f07a1df8e4eef6b85c9c699ad7fe700c546602f Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 22 Feb 2026 14:06:41 -0800 Subject: [PATCH 29/49] prelive: blue circles rather than green --- .../components/live-dashboard/bento-grid.tsx | 8 +++++-- .../live-dashboard/stream-monitor.tsx | 2 ++ .../src/components/dashboard/header.tsx | 8 +++++-- .../dashboard/information-widget.tsx | 24 ++++++++++++------- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/js/app/components/live-dashboard/bento-grid.tsx b/js/app/components/live-dashboard/bento-grid.tsx index d70bbc11..dcf2fa30 100644 --- a/js/app/components/live-dashboard/bento-grid.tsx +++ b/js/app/components/live-dashboard/bento-grid.tsx @@ -3,6 +3,7 @@ import { borders, Button, Dashboard, + useLivestream, useLivestreamStore, usePlayerStore, useProfile, @@ -81,6 +82,7 @@ export default function BentoGrid({ isLive, videoRef }: BentoGridProps) { const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); const ingestStarted = usePlayerStore((x) => x.ingestStarted); const emojiData = useEmojiData(); + const livestream = useLivestream(); // Calculate derived values const isConnected = ingestConnectionState === "connected"; @@ -112,8 +114,10 @@ export default function BentoGrid({ isLive, videoRef }: BentoGridProps) { | "excellent" | "good" | "poor" - | "offline" => { + | "offline" + | "pre-live" => { if (!isLive) return "offline"; + if (!livestream) return "pre-live"; switch (segmentTiming.connectionQuality) { case "good": return "excellent"; @@ -124,7 +128,7 @@ export default function BentoGrid({ isLive, videoRef }: BentoGridProps) { default: return "offline"; } - }, [isLive, segmentTiming.connectionQuality]); + }, [isLive, livestream, segmentTiming.connectionQuality]); // Calculate messages per minute const messagesPerMinute = useMemo((): number => { diff --git a/js/app/components/live-dashboard/stream-monitor.tsx b/js/app/components/live-dashboard/stream-monitor.tsx index 301963ec..f71a40c2 100644 --- a/js/app/components/live-dashboard/stream-monitor.tsx +++ b/js/app/components/live-dashboard/stream-monitor.tsx @@ -61,6 +61,7 @@ export default function StreamMonitor({ // Connection quality indicator const getConnectionIcon = () => { if (!isLive) return null; + if (!ls) return ; switch (segmentTiming.connectionQuality) { case "good": @@ -76,6 +77,7 @@ export default function StreamMonitor({ const getConnectionColor = () => { if (!isLive) return "red"; + if (!ls) return "blue"; switch (segmentTiming.connectionQuality) { case "good": diff --git a/js/components/src/components/dashboard/header.tsx b/js/components/src/components/dashboard/header.tsx index b5c74e68..f10710eb 100644 --- a/js/components/src/components/dashboard/header.tsx +++ b/js/components/src/components/dashboard/header.tsx @@ -36,7 +36,7 @@ function MetricItem({ icon: Icon, label, value, status }: MetricItemProps) { } interface StatusIndicatorProps { - status: "excellent" | "good" | "poor" | "offline"; + status: "excellent" | "good" | "poor" | "offline" | "pre-live"; isLive: boolean; } @@ -44,6 +44,8 @@ function StatusIndicator({ status, isLive }: StatusIndicatorProps) { const getStatusColor = () => { if (!isLive) return bg.gray[500]; switch (status) { + case "pre-live": + return bg.blue[500]; case "excellent": return bg.green[500]; case "good": @@ -60,6 +62,8 @@ function StatusIndicator({ status, isLive }: StatusIndicatorProps) { const getStatusText = () => { if (!isLive) return "OFFLINE"; switch (status) { + case "pre-live": + return "NOT LIVE"; case "excellent": return "EXCELLENT"; case "good": @@ -101,7 +105,7 @@ interface HeaderProps { uptime?: string; bitrate?: string; timeBetweenSegments?: number; - connectionStatus?: "excellent" | "good" | "poor" | "offline"; + connectionStatus?: "excellent" | "good" | "poor" | "offline" | "pre-live"; problemsCount?: number; onProblemsPress?: () => void; } diff --git a/js/components/src/components/dashboard/information-widget.tsx b/js/components/src/components/dashboard/information-widget.tsx index 07321f6b..bd299e81 100644 --- a/js/components/src/components/dashboard/information-widget.tsx +++ b/js/components/src/components/dashboard/information-widget.tsx @@ -13,11 +13,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; import { useAQState } from "../../hooks"; -import { - useLivestreamStore, - useSegment, - useViewers, -} from "../../livestream-store"; +import { useLivestream, useSegment, useViewers } from "../../livestream-store"; import * as zero from "../../ui"; import { InfoBox, InfoRow } from "../ui"; @@ -50,7 +46,7 @@ export default function InformationWidget({ const isCompactHeight = layoutMeasured && componentHeight < 350; const seg = useSegment(); - const livestream = useLivestreamStore((x) => x.livestream); + const livestream = useLivestream(); const viewers = useViewers(); const getBitrate = useCallback((): number => { @@ -173,8 +169,9 @@ export default function InformationWidget({ width: 8, height: 8, borderRadius: 4, - backgroundColor: - getConnectionStatus() === "good" + backgroundColor: !livestream + ? "#3b82f6" + : getConnectionStatus() === "good" ? "#22c55e" : getConnectionStatus() === "warning" ? "#f59e0b" @@ -182,6 +179,11 @@ export default function InformationWidget({ }, ]} /> + {!livestream && ( + + (not live) + + )} setShowViewers(!showViewers)} @@ -315,6 +317,7 @@ export default function InformationWidget({ data={bitrateHistory} width={componentWidth - 40} height={120} + color={livestream ? "#22c55e" : "#3b82f6"} /> )} @@ -395,6 +398,7 @@ export default function InformationWidget({ data={bitrateHistory} width={componentWidth - 40} height={isCompactHeight ? 80 : 120} + color={livestream ? "#22c55e" : "#3b82f6"} /> )} @@ -432,10 +436,12 @@ function BitrateChart({ data, width, height, + color = "#22c55e", }: { data: number[]; width: number; height: number; + color?: string; }) { const maxDataValue = Math.max(...data, 1); const minDataValue = Math.min(...data); @@ -515,7 +521,7 @@ function BitrateChart({ Date: Sun, 22 Feb 2026 15:19:37 -0800 Subject: [PATCH 30/49] prelive: checkbox to decide whether stream ends --- .../live-dashboard/livestream-panel.tsx | 56 ++++++-------- .../live-dashboard/stream-monitor.tsx | 68 +++++++++++++++-- .../mobile/desktop-ui/live-bubble.tsx | 14 +++- .../src/streamplace-store/stream.tsx | 3 + .../lex-reference/place-stream-livestream.md | 5 ++ lexicons/place/stream/livestream.json | 4 + pkg/atproto/sync.go | 30 ++++---- pkg/statedb/queue_processor.go | 9 ++- pkg/streamplace/cbor_gen.go | 74 ++++++++++++++++++- pkg/streamplace/streamlivestream.go | 2 + 10 files changed, 204 insertions(+), 61 deletions(-) diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index e7e35587..29384867 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -1,11 +1,9 @@ import { - Admonition, Button, Checkbox, ContentMetadataForm, Dashboard, formatHandle, - formatHandleWithAt, getBlob, Input, resolveDIDDocument, @@ -21,12 +19,11 @@ import { zero, } from "@streamplace/components"; import { error } from "console"; -import { ArrowRight, ImagePlus, X } from "lucide-react-native"; +import { ImagePlus, X } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Image, Platform, - Pressable, ScrollView, TouchableOpacity, View, @@ -113,7 +110,7 @@ const ImageUploadComponent = ({ r.md, layout.flex.center, { - height: 200, + height: 100, borderStyle: "dashed", }, ], @@ -186,7 +183,7 @@ const ImageUploadComponent = ({ )} )} - + {/* You are required to disclose if your content is not suitable for @@ -199,7 +196,7 @@ const ImageUploadComponent = ({ - + */} ); }; @@ -226,6 +223,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { ); const [createPost, setCreatePost] = useState(true); + const [idleTimeout, setIdleTimeout] = useState(true); const [sendPushNotification, setSendPushNotification] = useState(true); const [canonicalUrl, setCanonicalUrl] = useState(""); const defaultCanonicalUrl = useMemo(() => { @@ -299,6 +297,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { pushNotification: sendPushNotification, }, canonicalUrl: canonicalUrl || undefined, + idleTimeoutSeconds: idleTimeout ? 300 : 0, }); } else { await updateStreamRecord( @@ -421,8 +420,11 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { ? "Waiting for stream to start..." : "Waiting for stream to start..."; } - return mode === "create" ? "Announce Livestream!" : "Update Livestream!"; - }, [loading, userIsLive, mode]); + if (!livestream || livestream.record.endedAt !== undefined) { + return "Start Livestream!"; + } + return "Update Livestream!"; + }, [loading, userIsLive, mode, livestream]); const Wrapper = scrollable ? ScrollView : View; const wrapperProps = scrollable @@ -503,30 +505,6 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { ]} > - - - Streamer - - - {profile && formatHandleWithAt(profile)} - - + + + setIdleTimeout(checked)} + label={"End livestream automatically"} + style={[{ fontSize: 12 }]} + /> + {/* Image upload for create mode */} diff --git a/js/app/components/live-dashboard/stream-monitor.tsx b/js/app/components/live-dashboard/stream-monitor.tsx index f71a40c2..8c8d6e02 100644 --- a/js/app/components/live-dashboard/stream-monitor.tsx +++ b/js/app/components/live-dashboard/stream-monitor.tsx @@ -12,6 +12,7 @@ import { OfflineCounter } from "components/mobile/offline-counter"; import { Eye, EyeOff, Signal, Wifi, WifiOff } from "lucide-react-native"; import { useEffect, useState } from "react"; import { Image, TouchableOpacity, View } from "react-native"; +import Animated from "react-native-reanimated"; import { useLiveUser } from "../../hooks/useLiveUser"; import { useSegmentTiming } from "../../hooks/useSegmentTiming"; import StreamScreen from "./live-selector"; @@ -24,6 +25,52 @@ interface StreamMonitorProps { videoRef?: any; } +function PreviewOverlay() { + // const opacity = useSharedValue(1); + + // useEffect(() => { + // opacity.value = withRepeat(withTiming(0.8, { duration: 1500 }), -1, true); + // }, [opacity]); + + // const animatedStyle = useAnimatedStyle(() => ({ + // opacity: opacity.value, + // })); + + return ( + + + PREVIEW (NOT LIVE) + + + ); +} + export default function StreamMonitor({ userProfile: propUserProfile, isLive: propIsLive, @@ -139,15 +186,20 @@ export default function StreamMonitor({ {isLive && userProfile ? ( isStreamVisible ? ( - - - - - + + + + + + {!ls && } + ) : ( { return seg?.startTime ? new Date(seg.startTime) : undefined; }, [seg?.startTime]); @@ -51,7 +59,7 @@ export function LiveBubble() { { flexDirection: "row", alignItems: "center" }, gap.all[1], px[2], - bg.destructive[500], + livestream ? bg.destructive[500] : bg.gray[500], borders.color.gray[800], { paddingVertical: 3 }, ]} @@ -67,7 +75,7 @@ export function LiveBubble() { }, ]} > - LIVE + {livestream ? "LIVE" : "NOT LIVE"} diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index 59bb2d5d..c1776e2a 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -134,12 +134,14 @@ export function useCreateStreamRecord() { submitPost, canonicalUrl, notificationSettings, + idleTimeoutSeconds, }: { title: string; customThumbnail?: Blob; submitPost?: boolean; canonicalUrl?: string; notificationSettings?: PlaceStreamLivestream.NotificationSettings; + idleTimeoutSeconds?: number; }) => { if (typeof submitPost !== "boolean") { submitPost = true; @@ -263,6 +265,7 @@ export function useCreateStreamRecord() { agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, post: newPost, thumb: thumbnail, + idleTimeoutSeconds: idleTimeoutSeconds, }; console.log("record", record); diff --git a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md index 15ae29d5..2825bf4c 100644 --- a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md +++ b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md @@ -26,6 +26,7 @@ Record announcing a livestream is happening | `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` | | `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the Streamplace station. | Format: `datetime` | | `endedAt` | `string` | ❌ | Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. | Format: `datetime` | +| `idleTimeoutSeconds` | `integer` | ❌ | Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. | | | `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | | | `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: ` / ` e.g. Streamplace/0.7.5 iOS | | | `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | @@ -169,6 +170,10 @@ Record announcing a livestream is happening "format": "datetime", "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again." }, + "idleTimeoutSeconds": { + "type": "integer", + "description": "Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout." + }, "post": { "type": "ref", "ref": "com.atproto.repo.strongRef", diff --git a/lexicons/place/stream/livestream.json b/lexicons/place/stream/livestream.json index 0afa9684..e02f79a7 100644 --- a/lexicons/place/stream/livestream.json +++ b/lexicons/place/stream/livestream.json @@ -36,6 +36,10 @@ "format": "datetime", "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again." }, + "idleTimeoutSeconds": { + "type": "integer", + "description": "Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout." + }, "post": { "type": "ref", "ref": "com.atproto.repo.strongRef", diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 1ec2dabb..ccd072c0 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -372,20 +372,24 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD task := &statedb.FinalizeLivestreamTask{ LivestreamURI: aturi.String(), } - if rec.LastSeenAt != nil { - scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt) - if err == nil { - scheduledAt = scheduledAt.Add(constants.LivestreamInactiveCheckInterval).UTC() - taskKey := fmt.Sprintf("finalize-livestream::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601)) - log.Warn(ctx, "queueing remove red circle task", "taskKey", taskKey, "scheduledAt", scheduledAt) - _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestream, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt)) - if err != nil { - return fmt.Errorf("failed to enqueue remove red circle task: %w", err) - } - } else { - log.Error(ctx, "failed to parse last seen at", "err", err) - } + if rec.LastSeenAt == nil || rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 || rec.EndedAt != nil { + return nil } + scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt) + if err != nil { + log.Error(ctx, "failed to parse last seen at", "err", err) + return nil + } + + // if we check after exactly rec.IdleTimeoutSeconds we might miss the finalization by a few seconds + scheduledAt = scheduledAt.Add((time.Duration(*rec.IdleTimeoutSeconds) * time.Second) + (10 * time.Second)).UTC() + taskKey := fmt.Sprintf("finalize-livestream::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601)) + log.Warn(ctx, "queueing stream finalization task", "taskKey", taskKey, "scheduledAt", scheduledAt) + _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestream, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt)) + if err != nil { + return fmt.Errorf("failed to enqueue remove red circle task: %w", err) + } + } case *streamplace.LiveTeleport: diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 3e9b577f..54d0892a 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -12,7 +12,6 @@ import ( lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/xrpc" "gorm.io/gorm" - "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/integrations/webhook" "stream.place/streamplace/pkg/log" notificationpkg "stream.place/streamplace/pkg/notifications" @@ -105,8 +104,12 @@ func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task if err != nil { return fmt.Errorf("could not parse last seen at: %w", err) } - if time.Since(lastSeenTime) < constants.LivestreamConsideredInactiveAfter { - log.Warn(ctx, "livestream is active, skipping removal of red circle", "lastSeenAt", lastSeenTime) + if rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 { + log.Warn(ctx, "livestream has no idle timeout, skipping finalization", "uri", livestream.URI) + return nil + } + if time.Since(lastSeenTime) < (time.Duration(*rec.IdleTimeoutSeconds) * time.Second) { + log.Warn(ctx, "livestream is active, skipping finalization", "lastSeenAt", lastSeenTime) return nil } session, err := state.GetSessionByDID(livestream.RepoDID) diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index 628ae01e..0e88d5fb 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -250,7 +250,7 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 11 + fieldCount := 12 if t.Agent == nil { fieldCount-- @@ -264,6 +264,10 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.IdleTimeoutSeconds == nil { + fieldCount-- + } + if t.LastSeenAt == nil { fieldCount-- } @@ -551,6 +555,38 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } } + // t.IdleTimeoutSeconds (int64) (int64) + if t.IdleTimeoutSeconds != nil { + + if len("idleTimeoutSeconds") > 1000000 { + return xerrors.Errorf("Value in field \"idleTimeoutSeconds\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("idleTimeoutSeconds"))); err != nil { + return err + } + if _, err := cw.WriteString(string("idleTimeoutSeconds")); err != nil { + return err + } + + if t.IdleTimeoutSeconds == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if *t.IdleTimeoutSeconds >= 0 { + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.IdleTimeoutSeconds)); err != nil { + return err + } + } else { + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.IdleTimeoutSeconds-1)); err != nil { + return err + } + } + } + + } + // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) if t.NotificationSettings != nil { @@ -791,6 +827,42 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { t.CanonicalUrl = (*string)(&sval) } } + // t.IdleTimeoutSeconds (int64) (int64) + case "idleTimeoutSeconds": + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + var extraI int64 + switch maj { + case cbg.MajUnsignedInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 positive overflow") + } + case cbg.MajNegativeInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 negative overflow") + } + extraI = -1 - extraI + default: + return fmt.Errorf("wrong type for int64 field: %d", maj) + } + + t.IdleTimeoutSeconds = (*int64)(&extraI) + } + } // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) case "notificationSettings": diff --git a/pkg/streamplace/streamlivestream.go b/pkg/streamplace/streamlivestream.go index 6b4b481f..aef26c79 100644 --- a/pkg/streamplace/streamlivestream.go +++ b/pkg/streamplace/streamlivestream.go @@ -27,6 +27,8 @@ type Livestream struct { CreatedAt string `json:"createdAt" cborgen:"createdAt"` // endedAt: Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. EndedAt *string `json:"endedAt,omitempty" cborgen:"endedAt,omitempty"` + // idleTimeoutSeconds: Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. + IdleTimeoutSeconds *int64 `json:"idleTimeoutSeconds,omitempty" cborgen:"idleTimeoutSeconds,omitempty"` // lastSeenAt: Client-declared timestamp when this livestream was last seen by the Streamplace station. LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"` NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"` -- 2.51.2 From 5f5e5eb0a370857ddac7d0349dcb6ebc5d189110 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 22 Feb 2026 15:40:59 -0800 Subject: [PATCH 31/49] prelive: hide unpublished streams from the dashboard --- js/app/src/router.tsx | 22 +++++++++++++--------- pkg/localdb/localdb.go | 2 +- pkg/localdb/segment.go | 19 ++++++++++++------- pkg/media/clip_user.go | 2 +- pkg/media/validate.go | 1 + pkg/spxrpc/place_stream_live.go | 5 ++++- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx index 290a38df..055b7a4f 100644 --- a/js/app/src/router.tsx +++ b/js/app/src/router.tsx @@ -543,16 +543,20 @@ export function StreamplaceDrawer() { const [isLiveDashboard, setIsLiveDashboard] = useState(false); useEffect(() => { if (!isLiveDashboard && userIsLive) { - toast.show("You are live!", "Do you want to go to your Live Dashboard?", { - actionLabel: "Go", - onAction: () => { - navigation.navigate("LiveDashboard"); - setLivePopup(false); + toast.show( + "You are streaming!", + "Do you want to go to your Live Dashboard?", + { + actionLabel: "Go", + onAction: () => { + navigation.navigate("LiveDashboard"); + setLivePopup(false); + }, + onClose: () => setLivePopup(false), + variant: "error", + duration: 8, }, - onClose: () => setLivePopup(false), - variant: "error", - duration: 8, - }); + ); } }, [userIsLive]); const externalItems = useExternalItems(); diff --git a/pkg/localdb/localdb.go b/pkg/localdb/localdb.go index 0b186bc0..64ee5b15 100644 --- a/pkg/localdb/localdb.go +++ b/pkg/localdb/localdb.go @@ -17,7 +17,7 @@ type LocalDB interface { CreateSegment(segment *Segment) error MostRecentSegments() ([]Segment, error) LatestSegmentForUser(user string) (*Segment, error) - LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) + LatestSegmentsForUser(user string, limit int, includeUnpublished bool, before *time.Time, after *time.Time) ([]Segment, error) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) CreateThumbnail(thumb *Thumbnail) error LatestThumbnailForUser(user string) (*Thumbnail, error) diff --git a/pkg/localdb/segment.go b/pkg/localdb/segment.go index 3cfc810c..d8d8b667 100644 --- a/pkg/localdb/segment.go +++ b/pkg/localdb/segment.go @@ -138,8 +138,8 @@ func (c ContentWarningsSlice) Value() (driver.Value, error) { type Segment struct { ID string `json:"id" gorm:"primaryKey"` SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` - StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` - RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` + StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time;index:latest_segments_published,priority:2"` + RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did;index:latest_segments_published,priority:1"` Title string `json:"title"` Size int `json:"size" gorm:"column:size"` MediaData *SegmentMediaData `json:"mediaData,omitempty"` @@ -147,6 +147,7 @@ type Segment struct { ContentRights *ContentRights `json:"contentRights,omitempty"` DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` + Published bool `json:"published" gorm:"column:published;index:latest_segments_published,priority:3"` } func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { @@ -238,7 +239,7 @@ func (m *LocalDatabase) MostRecentSegments() ([]Segment, error) { err := m.DB.Table("segments"). Select("segments.*"). - Where("start_time > ?", thirtySecondsAgo.UTC()). + Where("start_time > ? AND published = ?", thirtySecondsAgo.UTC(), true). Order("start_time DESC"). Find(&segments).Error if err != nil { @@ -270,7 +271,7 @@ func (m *LocalDatabase) MostRecentSegments() ([]Segment, error) { func (m *LocalDatabase) LatestSegmentForUser(user string) (*Segment, error) { var seg Segment - err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error + err := m.DB.Model(Segment{}).Where("repo_did = ? AND published = ?", user, true).Order("start_time DESC").First(&seg).Error if err != nil { return nil, err } @@ -288,7 +289,7 @@ func (m *LocalDatabase) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) err := m.DB.Table("segments"). Select("DISTINCT repo_did"). - Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). + Where("repo_did IN ? AND start_time > ? AND published = ?", repoDIDs, thirtySecondsAgo.UTC(), true). Pluck("repo_did", &liveDIDs).Error if err != nil { @@ -298,7 +299,7 @@ func (m *LocalDatabase) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) return liveDIDs, nil } -func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { +func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, includeUnpublished bool, before *time.Time, after *time.Time) ([]Segment, error) { var segs []Segment if before == nil { later := time.Now().Add(1000 * time.Hour) @@ -308,7 +309,11 @@ func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, before *ti earlier := time.Time{} after = &earlier } - err := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()).Order("start_time DESC").Limit(limit).Find(&segs).Error + query := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()) + if !includeUnpublished { + query = query.Where("published = ?", true) + } + err := query.Order("start_time DESC").Limit(limit).Find(&segs).Error if err != nil { return nil, err } diff --git a/pkg/media/clip_user.go b/pkg/media/clip_user.go index 51f34f3e..fe365c44 100644 --- a/pkg/media/clip_user.go +++ b/pkg/media/clip_user.go @@ -14,7 +14,7 @@ import ( ) func ClipUser(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { - segments, err := localDB.LatestSegmentsForUser(user, -1, before, after) + segments, err := localDB.LatestSegmentsForUser(user, -1, false, before, after) if err != nil { return fmt.Errorf("unable to get segments: %w", err) } diff --git a/pkg/media/validate.go b/pkg/media/validate.go index f28bae31..871ab4ed 100644 --- a/pkg/media/validate.go +++ b/pkg/media/validate.go @@ -129,6 +129,7 @@ func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader, local ContentRights: meta.ContentRights, DistributionPolicy: meta.DistributionPolicy, DeleteAfter: deleteAfter, + Published: meta.Published, } mm.newSegmentSubsMutex.RLock() defer mm.newSegmentSubsMutex.RUnlock() diff --git a/pkg/spxrpc/place_stream_live.go b/pkg/spxrpc/place_stream_live.go index a84f0b0a..9d6e0169 100644 --- a/pkg/spxrpc/place_stream_live.go +++ b/pkg/spxrpc/place_stream_live.go @@ -82,7 +82,10 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st beforeTime = &parsedTime } - segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, beforeTime, nil) + sess, _ := oatproxy.GetOAuthSession(ctx) + includeUnpublished := sess != nil && sess.DID == userDID + + segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, includeUnpublished, beforeTime, nil) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch segments") } -- 2.51.2 From 9d8c3a852b4755998846d26a02bf7a5c5d557bca Mon Sep 17 00:00:00 2001 From: Laszlo19 Date: Mon, 23 Feb 2026 13:57:10 +0200 Subject: [PATCH 32/49] Add files via upload --- js/components/src/i18n/i18n-loader.native.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/components/src/i18n/i18n-loader.native.ts b/js/components/src/i18n/i18n-loader.native.ts index 2bca13fc..b90d0b95 100644 --- a/js/components/src/i18n/i18n-loader.native.ts +++ b/js/components/src/i18n/i18n-loader.native.ts @@ -10,6 +10,8 @@ import frFRCommon from "../../public/locales/fr-FR/common.json"; import frFRSettings from "../../public/locales/fr-FR/settings.json"; import ptBRCommon from "../../public/locales/pt-BR/common.json"; import ptBRSettings from "../../public/locales/pt-BR/settings.json"; +import roROCommon from "../../public/locales/ro-RO/common.json"; +import roROSettings from "../../public/locales/ro-RO/settings.json"; import zhHansCommon from "../../public/locales/zh-Hans/common.json"; import zhHansSettings from "../../public/locales/zh-Hans/settings.json"; import zhHantCommon from "../../public/locales/zh-Hant/common.json"; @@ -28,6 +30,8 @@ const translationMap: Record = { "zh-Hant/settings": zhHantSettings, "fr-FR/common": frFRCommon, "fr-FR/settings": frFRSettings, + "ro-RO/common": roROCommon, + "ro-RO/settings": roROSettings, }; export async function loadTranslationData( @@ -43,6 +47,7 @@ export async function loadTranslationData( es: "es-ES", zh: "zh-Hant", fr: "fr-FR", + ro: "ro-RO", }[locale] || locale; const localeNamespaceKey = `${fullLocale}/${namespace}`; -- 2.51.2 From cdfc2501becbad6db5299e3198ecfd49ed603d4b Mon Sep 17 00:00:00 2001 From: Laszlo19 Date: Mon, 23 Feb 2026 13:59:06 +0200 Subject: [PATCH 33/49] Add files via upload --- js/components/locales/manifest.json | 9 +- js/components/locales/ro-RO/common.ftl | 74 +++++++ js/components/locales/ro-RO/settings.ftl | 233 +++++++++++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 js/components/locales/ro-RO/common.ftl create mode 100644 js/components/locales/ro-RO/settings.ftl diff --git a/js/components/locales/manifest.json b/js/components/locales/manifest.json index ad8a20e3..cfc4a62a 100644 --- a/js/components/locales/manifest.json +++ b/js/components/locales/manifest.json @@ -5,7 +5,8 @@ "es-ES", "zh-Hant", "fr-FR", - "zh-Hans" + "zh-Hans", + "ro-RO" ], "fallbackChain": ["en-US"], "languages": { @@ -44,6 +45,12 @@ "name": "Chinese (Simplified)", "nativeName": "简体中文", "flag": "汉" + }, + "ro-RO": { + "code": "ro-RO", + "name": "Romanian", + "nativeName": "Română", + "flag": "🇷🇴" } } } diff --git a/js/components/locales/ro-RO/common.ftl b/js/components/locales/ro-RO/common.ftl new file mode 100644 index 00000000..db4e0ccb --- /dev/null +++ b/js/components/locales/ro-RO/common.ftl @@ -0,0 +1,74 @@ +# Common UI Translations - English (US) + +## General UI +loading = Se încarcă... +error = Eroare +cancel = Anulare +confirm = Confirmare +close = Închidere +open = Deschidere +ok = OK +yes = Da +no = Nu +continue = Continuare +back = Înapoi +next = Următorul +finish = Finalizare + +## Actions +save = Salvare +delete = Ștergere +edit = Editare +create = Creare +update = Actualizare +refresh = Reîmprospătare + +## Status Messages +success = Succes +warning = Avertisment +info = Informație + +## Input Placeholders +search-placeholder = Căutare... +message-input = Introduceți-vă mesajul... + +## Authentication & Access +please-log-in-to-access-this-page = Vă rugăm să vă conectați pentru a accesa această pagină +go-to-settings = Mergeți la Setări +go-back = Mergeți înapoi + +## Demo and Testing +welcome-user = Bun venit, { $username }! +notification-count = { $count -> + [0] Nici o notificare + [1] O notificare + *[other] { $count } (de) notificări +} + +## Offline User +user-offline = utilizatorul este offline +user-offline-message = { $source -> + [streamer] Se pare că <1>@{ $handle } este offline, dar recomandă să vizionați: + *[default] Se pare că <1>@{ $handle } este offline, dar vă recomandăm să vizionați: +} +user-offline-no-recommendations = + Se pare că <1>@{ $handle } este offline acum. + Reveniți mai târziu. +streaming-title = transmisie în direct { $title } +viewer-count = { $count -> + [0] 0 vizionări + [1] 1 vizionare + *[other] { $count } (de) vizionări +} + +## PDS Host Selector +pds-selector-title = Nou în Atmosferă? +pds-selector-description = Va trebui să selectați un PDS (Personal Data Server) pentru a accesa aplicațiile de pe Atmosphere, cum ar fi Bluesky, Tangled și Spark. +pds-selector-custom-label = Alt PDS +pds-selector-custom-description = Introduceți adresa URL a propriei gazde PDS +pds-selector-custom-url-label = URL PDS personalizat +pds-selector-custom-url-placeholder = https://pds.exemplu.ro +pds-selector-learn-more = Aflați mai multe despre găzduirea proprie +pds-selector-info = Fiecare gazdă are propriile politici și standarde de fiabilitate. Datele dvs. ATProto se află pe gazda pe care o alegeți și le puteți migra ulterior. Notă: Streamplace are propriile reguli de moderare - accesul dvs. poate fi interzis pe Streamplace indiferent de gazda pe care o alegeți. +pds-selector-read-policies = Citiți Termenii și condițiile și Politica de confidențialitate ale { $label } înainte de a continua. +pds-selector-handle-policy-checkbox = Am citit și sunt de acord cu politica de gestionare diff --git a/js/components/locales/ro-RO/settings.ftl b/js/components/locales/ro-RO/settings.ftl new file mode 100644 index 00000000..572cd560 --- /dev/null +++ b/js/components/locales/ro-RO/settings.ftl @@ -0,0 +1,233 @@ +# Settings Page Translations - Romanian (Romania) + +## App Version +app-version = Streamplace v{ $version } +download-new-update = Descărcați noua actualizare +check-for-updates = Căutați actualizări +bundled-runtype = Bundled +ota-runtype = Over-the-Air (OTA) +recovery-runtype = Mod de recuperare + +modal-latest-version = Aveți cea mai recentă versiune. +modal-no-update-available = Aveți cea mai recentă versiune de Streamplace, ura! +modal-update-available-title = Actualizare disponibilă +modal-update-available-description = O nouă versiune de Streamplace este gata de descărcare +modal-update-failed = Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin { $store }. +modal-update-failed-title = Actualizare eșuată +modal-update-failed-description = Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin { $store }. +button-reload-app-on-update = Aplicați actualizarea (va reîncărca aplicația) + +## Custom Node Settings +use-custom-node = Utilizați nodul personalizat +default-url = Default: { $url } +enter-custom-node-url = Introduceți adresa URL a nodului personalizat +save-button = SALVARE + +## Language Settings +language-selection = Limbă +language-selection-description = Alegeți-vă limba preferată +input-search-languages = Căutați limbi... +help-translate = Ajutați-ne să traducem Streamplace +help-translate-description = Căutăm voluntari care să ne ajute la traducerea Streamplace în mai multe limbi. Dacă sunteți interesați, vă rugăm să ne contactați pe Discord sau GitHub! +currently-translating = Traducerile sunt pe drum +currently-translating-description = Este posibil ca unele părți ale aplicației să pară incomplete. Vă mulțumim pentru răbdare! + +## Debug Recording +debug-recording-title = Permiteți-i lui { $host } să înregistreze transmisiunea live pentru depanare și îmbunătățirea serviciului +debug-recording-description = Opțional + +## Key Management +manage-keys = Gestionare Chei + +## Settings Page Specific +settings-title = Setări + +## Navigation Categories +about = Despre +account = Cont +advanced = Complex +danmu = Danmu +developer = Dezvoltator +languages = Limbi +privacy-security = Confidențialitate și securitate +streaming = Streaming + +## Common Actions +cancel = Anulare +create = Creare +delete = Ștergere +refresh = Reîmprospătare +save-button = Salvare +sign-in = Conectare +update = Actualizare +log-out = Deconectare +optional = opțional + +## Account Settings +account-greeting = Hei, @{ $handle }. +edit-profile-bluesky = Editare profil (pe Bluesky) +change-name-color = Schimbați culoarea numelui + +## Key Management +key-management = Gestionare chei +key-manager = Manager cheie +manage-keys = Gestionare chei +your-stream-pubkeys = Cheile publice ale transmisiunii dvs. +no-keys = Nicio cheie configurată +pubkey-description = Cheile publice sunt asociate cu cheile de transmisiune (utilizate în software-ul de streaming) pentru a semna și verifica transmisiunea. +keys-count = { $count -> + [one] { $count } cheie + *[other] { $count } chei +} + +## Recommendations +recommendations = Recomandări +manage-recommendations = Gestionați recomandările +recommendations-to-others = Recomandări pentru Alții +recommendations-description = Distribuie până la 8 streameri pe care îi recomandați spectatorilor dvs. +no-recommendations-yet = Nu au fost configurate recomandări încă +add-recommendation = Adăugare recomandare +streamer-did = DID Streamer +recommendations-count = { $count -> + [one] { $count } recomandare + *[other] { $count } (de) recomandări +} + +## Webhook Management +webhooks = Webhook-uri +webhook-integrations = Integrari Webhook +webhook-integrations-description = Conectați servicii externe pentru a primi actualizări în timp real despre transmisiunile dvs. +create-webhook = Creare Webhook +edit-webhook = Editare Webhook +delete-webhook = Ștergere Webhook +no-webhooks-yet = Nu sunt încă configurate webhook-uri +failed-load-webhooks = Nu s-au putut încărca webhook-urile +webhook-will-no-longer-receive-events = Acest webhook nu va mai primi evenimente +create-first-webhook-description = Creați-vă primul webhook pentru a începe să primiți evenimente de transmisiune +example-captain-hook = Captain Hook +webhooks-count = { $count -> + [one] { $count } webhook + *[other] { $count } (de) webhook-uri +} + +## Webhook Events +activates-on = Se activează la: +events = Evenimente +events-livestream = Evenimente în transmisiuni +events-chat = Evenimente de conversașie +untitled-webhook = Webhook fără titlu +inactive = Inactiv +active = Activ + +## Multistreaming +multistream = Multi-transmisiune +multistream-targets = Ținte multi-transmisiune +multistream-description = Distribuie automat transmisiunile tale live Streamplace către alte servicii de streaming precum Twitch sau YouTube. +create-multistream-target = Creare țintă multi-transmisiune +untitled-multistream-target = Țintă fără titlu +failed-load-multistream-targets = Nu s-au putut încărca țintele multi-transmisiune. Vă rugăm să încercați din nou. +failed-toggle-multistream-target = Nu s-a putut comuta ținta multi-transmisiune. Vă rugăm să încercați din nou. +failed-delete-multistream-target = Ștergerea țintei multi-transmisiune nu a reușit. Încercați din nou. +no-multistream-targets-yet = Nu există încă ținte! +multistream-targets-count = { $count -> + [one] { $count } țintă + *[other] { $count } (de) ținte +} +multistream-delete-target-confirmation = Sunteți sigur că vreți să ștergeți „{ $target }”? +this-action-cannot-be-undone = Această acțiune nu poate fi anulată. +rtmp-target-name = Țintă RTMP +rtmp-target-url = URL RTMP +rtmp-target-name-placeholder = Ținta mea transmisiune +multistream-create-target = Creare țintă +multistream-edit-target = Editare țintă +created = creată +status = stare + +## Debug Recording +debug-recording = Depanare înregistrare + +## Danmu Settings +danmu = Danmu +danmu-enabled = Activare Danmu +danmu-enabled-description = Afișați mesajele din chatul live ca comentarii plutitoare pe ecran +danmu-opacity = Opacitate +danmu-speed = Viteză +danmu-lane-count = Număr de benzi +danmu-max-messages = Mesaje maxime + +## General +app-version-description = Nu sunt disponibile actualizări în acest moment +confirm-delete = Sigur vreți să ștergeți asta? +action-cannot-be-undone = Această acțiune nu poate fi anulată +name-optional = Nume (opțional) +deleting = Se șterge... +saving = Se salvează... +go-to-dashboard = Mergeți la tabloul de bord +need-setup-live-dashboard = Trebuie să configurați mai întâi transmisiunile? Vizitați tabloul de bord al transmisiunilor +no-languages-found = Nu s-au găsit limbi + +## Branding Administration +branding = Branding +branding-admin = Administrarea brandingului +branding-admin-description = Personalizați-vă instanța Streamplace. Rețineți că propagarea setărilor poate dura câteva ore. +branding-login-required = Vă rugăm să vă conectați pentru a gestiona brandingul +branding-configuration = Configurare +branding-text-settings = Setări text +branding-colors = Culori +branding-legal-links = Link-uri legale +branding-images = Imagini + +## Branding Fields +branding-broadcaster-did = DID radiodifuzor +branding-broadcaster-did-description = Lăsați gol pentru a utiliza valorile implicite ale serverului +branding-site-title = Titlul site-ului +branding-site-title-placeholder = Introduceți un nou titlu de site +branding-site-description = Descrierea site-ului +branding-site-description-placeholder = Introduceți descrierea site-ului +branding-default-streamer = Streamer implicit +branding-default-streamer-none = Nimic +branding-default-streamer-placeholder = did:plc:... +branding-clear-default-streamer = Ștergeți Streamerul implicit +branding-primary-color = Culoare primară +branding-primary-color-placeholder = #6366f1 +branding-accent-color = Culoare de accent +branding-accent-color-placeholder = #8b5cf6 +branding-main-logo = Logo principal +branding-main-logo-description = SVG, PNG, or JPEG (max 500KB) +branding-favicon = Favicon +branding-favicon-description = SVG, PNG, sau ICO (maxim 100KB) +branding-sidebar-bg = Imagine de fundal din bara laterală +branding-sidebar-bg-description = SVG, PNG sau JPEG (maxim 500kb) - apare aliniat la partea de jos a barei laterale, pe lățime completă. Încărcați o imagine cu opacitate pentru cele mai bune rezultate, deoarece în prezent nu există o opțiune separată de opacitate. +branding-current = Current: { $value } +branding-dimensions = { $height } x { $width } + +## Branding Actions +branding-upload-logo = Încărcare logo +branding-delete-logo = Ștergere logo +branding-upload-favicon = Încărcare Favicon +branding-delete-favicon = Ștergere Favicon +branding-upload-background = Încărcare imagine de fundal +branding-delete-background = Ștergere imagine de fundal +branding-web-only = Încărcarea imaginilor este disponibilă doar pe web. + +## Branding Legal Links +refresh-branding = Actualizați elementele de branding +branding-add-legal-link = Adăugați link legal +branding-edit-legal-link = Editați linkul legal +branding-legal-link-text-placeholder = Textul linkului (de exemplu, Politica de confidențialitate) +branding-legal-link-url-placeholder = Adresa URL (de ex., https://example.com/privacy) +add = Adăugare +edit = Editare + +## Branding Toast Messages +branding-not-authenticated = Vă rugăm să vă conectați mai întâi +branding-empty-value = Vă rugăm să introduceți o valoare +branding-update-success = { $key } a fost actualizat cu succes +branding-upload-success = { $key } a fost încărcat cu succes +branding-delete-success = { $key } a fost șters cu succes +branding-upload-failed = Încărcare eșuată +branding-delete-failed = Ștergere eșuată +branding-not-available = Încărcarea de fișiere este disponibilă doar pe web. + +## Navigation Categories (About Page) +node-legal-documents = Documente specifice radiodifuzorului -- 2.51.2 From 4cf9880d875e8bdd041525fcffb2c55cfac64d4a Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 23 Feb 2026 16:28:18 -0800 Subject: [PATCH 34/49] state: add broadcastorigin record --- pkg/atproto/lexicon_repo.go | 6 +-- pkg/atproto/lexicon_repo_test.go | 10 ++-- pkg/config/config.go | 6 ++- pkg/director/stream_session.go | 4 ++ pkg/media/manifest_builder.go | 9 ++++ .../websocketrep/websocket_replicator.go | 2 +- pkg/spxrpc/com_atproto_repo.go | 4 +- pkg/spxrpc/spxrpc.go | 4 +- pkg/statedb/broadcast_origin.go | 51 +++++++++++++++++++ pkg/statedb/statedb.go | 1 + 10 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 pkg/statedb/broadcast_origin.go diff --git a/pkg/atproto/lexicon_repo.go b/pkg/atproto/lexicon_repo.go index 9d2370fb..43c611fe 100644 --- a/pkg/atproto/lexicon_repo.go +++ b/pkg/atproto/lexicon_repo.go @@ -195,7 +195,7 @@ func MakeLexiconRepo(ctx context.Context, cli *config.CLI, mod model.Model, stat return priv.HashAndSign(sb) } - events, err := state.GetCommitEventsSince(cli.MyDID(), time.Time{}) + events, err := state.GetCommitEventsSince(cli.BroadcasterDID(), time.Time{}) if err != nil { return nil, fmt.Errorf("failed to get commit events: %w", err) } @@ -222,7 +222,7 @@ func MakeLexiconRepo(ctx context.Context, cli *config.CLI, mod model.Model, stat if err != nil { return nil, fmt.Errorf("failed to create delta session: %w", err) } - LexiconRepo = atrepo.NewRepo(ctx, cli.MyDID(), ses) + LexiconRepo = atrepo.NewRepo(ctx, cli.BroadcasterDID(), ses) } else { LexiconRepo, err = atrepo.OpenRepo(ctx, ses, currentRoot) if err != nil { @@ -316,7 +316,7 @@ func MakeLexiconRepo(ctx context.Context, cli *config.CLI, mod model.Model, stat if len(ops) > 0 { log.Log(ctx, "created new lexicon commit for changes", "did", signed.Did, "data", signed.Data, "prev", signed.Prev, "rev", signed.Rev) commit := &comatproto.SyncSubscribeRepos_Commit{ - Repo: cli.MyDID(), + Repo: cli.BroadcasterDID(), Blocks: blocks, Rev: currentRev, Commit: lexutil.LexLink(currentRoot), diff --git a/pkg/atproto/lexicon_repo_test.go b/pkg/atproto/lexicon_repo_test.go index f014701a..14c611e1 100644 --- a/pkg/atproto/lexicon_repo_test.go +++ b/pkg/atproto/lexicon_repo_test.go @@ -40,10 +40,10 @@ func TestLexiconRepo(t *testing.T) { require.NotNil(t, rec) handle.Close() - evts, err := state.GetCommitEventsSinceSeq(cli.MyDID(), 0) + evts, err := state.GetCommitEventsSinceSeq(cli.BroadcasterDID(), 0) require.NoError(t, err) require.Len(t, evts, 1) - require.Equal(t, evts[0].RepoDID, cli.MyDID()) + require.Equal(t, evts[0].RepoDID, cli.BroadcasterDID()) // opening an existing repo handle, err = MakeLexiconRepo(context.Background(), &cli, mod, state) @@ -100,11 +100,11 @@ func TestLexiconRepo(t *testing.T) { require.NoError(t, err) handle.Close() - evts, err = state.GetCommitEventsSinceSeq(cli.MyDID(), 0) + evts, err = state.GetCommitEventsSinceSeq(cli.BroadcasterDID(), 0) require.NoError(t, err) require.Len(t, evts, 2) - require.Equal(t, evts[0].RepoDID, cli.MyDID()) - require.Equal(t, evts[1].RepoDID, cli.MyDID()) + require.Equal(t, evts[0].RepoDID, cli.BroadcasterDID()) + require.Equal(t, evts[1].RepoDID, cli.BroadcasterDID()) oldCommit, err := evts[0].ToCommitEvent() require.NoError(t, err) newCommit, err := evts[1].ToCommitEvent() diff --git a/pkg/config/config.go b/pkg/config/config.go index b9e268be..66e9fbe2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1152,10 +1152,14 @@ func (cli *CLI) StreamIsAllowed(did string) error { return fmt.Errorf("user is not allowed to stream") } -func (cli *CLI) MyDID() string { +func (cli *CLI) BroadcasterDID() string { return fmt.Sprintf("did:web:%s", cli.BroadcasterHost) } +func (cli *CLI) ServerDID() string { + return fmt.Sprintf("did:web:%s", cli.ServerHost) +} + func (cli *CLI) HasHTTPS() bool { return cli.Secure || cli.BehindHTTPSProxy } diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index d16abdae..324da01c 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -214,6 +214,10 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment }) }) + ss.Go(ctx, func() error { + return ss.statefulDB.UpsertBroadcastOrigin(spseg.Creator, ss.cli.BroadcasterDID(), time.Now()) + }) + // everything else is for published segments if !notif.Metadata.Published { return nil diff --git a/pkg/media/manifest_builder.go b/pkg/media/manifest_builder.go index c3902b6f..1740798c 100644 --- a/pkg/media/manifest_builder.go +++ b/pkg/media/manifest_builder.go @@ -156,6 +156,15 @@ func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName strin // Update the manifest title with the retrieved livestream title mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = livestreamTitle + if ls != nil { + mani["assertions"] = append(mani["assertions"].([]obj), obj{ + "label": "place.stream.livestream", + "data": ls, + }) + } else { + log.Warn(ctx, "ManifestBuilder: no livestream found for streamer", "did", streamerName) + } + // Convert manifest to JSON bytes for use with Rust c2pa library manifestBs, err := json.Marshal(mani) if err != nil { diff --git a/pkg/replication/websocketrep/websocket_replicator.go b/pkg/replication/websocketrep/websocket_replicator.go index 0a6b1fd5..e51e0ca2 100644 --- a/pkg/replication/websocketrep/websocket_replicator.go +++ b/pkg/replication/websocketrep/websocket_replicator.go @@ -123,7 +123,7 @@ func (r *WebsocketReplicator) openWebsocket(ctx context.Context, view *streampla } conn, _, err := websocket.DefaultDialer.Dial(*origin.WebsocketURL, nil) if err != nil { - return fmt.Errorf("could not dial websocket: %w", err) + return fmt.Errorf("could not dial websocket (%s): %w", *origin.WebsocketURL, err) } defer conn.Close() for { diff --git a/pkg/spxrpc/com_atproto_repo.go b/pkg/spxrpc/com_atproto_repo.go index 328376d3..52d41038 100644 --- a/pkg/spxrpc/com_atproto_repo.go +++ b/pkg/spxrpc/com_atproto_repo.go @@ -92,8 +92,8 @@ func (s *Server) handleComAtprotoRepoDescribeRepo(ctx context.Context, repo stri } return &comatproto.RepoDescribeRepo_Output{ - Handle: s.cli.MyDID(), - Did: s.cli.MyDID(), + Handle: s.cli.BroadcasterDID(), + Did: s.cli.BroadcasterDID(), DidDoc: atproto.DIDDoc(s.cli.BroadcasterHost), Collections: []string{ "com.atproto.lexicon.schema", diff --git a/pkg/spxrpc/spxrpc.go b/pkg/spxrpc/spxrpc.go index cf00df19..8265630d 100644 --- a/pkg/spxrpc/spxrpc.go +++ b/pkg/spxrpc/spxrpc.go @@ -85,7 +85,7 @@ func (s *Server) isLocalPDS(ctx context.Context, repo string) (bool, string, err if err != nil { return false, "", fmt.Errorf("resolveRepoService: %w", err) } - if did == s.cli.MyDID() { + if did == s.cli.BroadcasterDID() { return true, svc, nil } return false, svc, nil @@ -106,7 +106,7 @@ func makeUnauthenticatedRequest(ctx context.Context, service, method string, par } u.RawQuery = query.Encode() - log.Error(ctx, "making unauthenticated request", "url", u.String()) + log.Debug(ctx, "making unauthenticated request", "url", u.String()) req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) if err != nil { diff --git a/pkg/statedb/broadcast_origin.go b/pkg/statedb/broadcast_origin.go new file mode 100644 index 00000000..c53691a9 --- /dev/null +++ b/pkg/statedb/broadcast_origin.go @@ -0,0 +1,51 @@ +package statedb + +import ( + "time" +) + +type BroadcastOrigin struct { + StreamerRepoDID string `gorm:"column:streamer_repo_did;primarykey;index:idx_streamer_repo_did_updated_at,priority:1"` + ServerDID string `gorm:"column:server_did;primarykey;index:idx_server_did_updated_at,priority:1"` + UpdatedAt time.Time `gorm:"column:updated_at;index:idx_streamer_repo_did_updated_at,priority:2;index:idx_server_did_updated_at,priority:2"` +} + +func (m *BroadcastOrigin) TableName() string { + return "broadcast_origins" +} + +// UpsertBroadcastOrigin inserts or updates a BroadcastOrigin entry. +// If an entry with the same StreamerRepoDID and ServerRepoDID exists, it updates UpdatedAt. +// Otherwise, it creates a new entry. +func (state *StatefulDB) UpsertBroadcastOrigin(streamerRepoDID, serverRepoDID string, updatedAt time.Time) error { + broadcastOrigin := &BroadcastOrigin{ + StreamerRepoDID: streamerRepoDID, + ServerDID: serverRepoDID, + UpdatedAt: updatedAt, + } + // Uses GORM's upsert ("ON CONFLICT DO UPDATE") by providing primary keys and using Updates + return state.DB. + Clauses( + // GORM uses these settings to upsert + // The clause 'ON CONFLICT (primary key) DO UPDATE' is default when calling Save + ). + Save(broadcastOrigin).Error +} + +// GetLatestBroadcastOriginForStreamer retrieves the most recent BroadcastOrigin for a given streamerRepoDID, +// ordered by UpdatedAt descending, and returns the first found. +func (state *StatefulDB) GetLatestBroadcastOriginForStreamer(streamerRepoDID string) (*BroadcastOrigin, error) { + var origin BroadcastOrigin + tx := state.DB. + Where("streamer_repo_did = ?", streamerRepoDID). + Order("updated_at DESC"). + Limit(1). + Find(&origin) + if tx.Error != nil { + return nil, tx.Error + } + if tx.RowsAffected == 0 { + return nil, nil + } + return &origin, nil +} diff --git a/pkg/statedb/statedb.go b/pkg/statedb/statedb.go index c1e06e1f..604fbf08 100644 --- a/pkg/statedb/statedb.go +++ b/pkg/statedb/statedb.go @@ -53,6 +53,7 @@ var StatefulDBModels = []any{ MultistreamEvent{}, BrandingBlob{}, ModerationAuditLog{}, + BroadcastOrigin{}, } var NoPostgresDatabaseCode = "3D000" -- 2.51.2 From 0ae6327f842ee88d2ae0a887d82c272631193ebe Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 23 Feb 2026 17:21:42 -0800 Subject: [PATCH 35/49] spxrpc: add service auth capability --- pkg/cmd/streamplace.go | 6 ++ pkg/config/config.go | 1 + pkg/spxrpc/place_stream_playback.go | 59 ++++++++++++++-- pkg/spxrpc/service_auth.go | 100 ++++++++++++++++++++++++++++ pkg/spxrpc/spxrpc.go | 1 + pkg/statedb/service_auth.go | 52 +++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 pkg/spxrpc/service_auth.go create mode 100644 pkg/statedb/service_auth.go diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 4a399290..6e1cf4ba 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -215,6 +215,12 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu } cli.AccessJWK = accessJWK + serviceAuthKey, err := state.EnsureServiceAuthKey(ctx) + if err != nil { + return err + } + cli.ServiceAuthKey = serviceAuthKey + b := bus.NewBus() atsync := &atproto.ATProtoSynchronizer{ CLI: cli, diff --git a/pkg/config/config.go b/pkg/config/config.go index 66e9fbe2..6a37d957 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -116,6 +116,7 @@ type CLI struct { RateLimitWebsocket int JWK jwk.Key AccessJWK jwk.Key + ServiceAuthKey jwk.Key dataDirFlags []*string DiscordWebhooks []*discordtypes.Webhook NewWebRTCPlayback bool diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go index 9cca974c..1ad5223b 100644 --- a/pkg/spxrpc/place_stream_playback.go +++ b/pkg/spxrpc/place_stream_playback.go @@ -9,6 +9,8 @@ import ( "github.com/labstack/echo/v4" "github.com/pion/webrtc/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" + "stream.place/streamplace/pkg/aqhttp" + "stream.place/streamplace/pkg/log" ) func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { @@ -20,19 +22,68 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st return nil, echo.NewHTTPError(http.StatusBadRequest, "rendition is required") } viewer := "" - session, _ := oatproxy.GetOAuthSession(ctx) - if session != nil { - viewer = session.DID - } repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) if err != nil { return nil, err } + streamer = repo.DID + session, _ := oatproxy.GetOAuthSession(ctx) + if session != nil { + viewer = session.DID + } else { + svc := GetServiceAuth(ctx) + if svc != nil { + log.Warn(ctx, "service auth present", "service_did", svc.DID) + // this is a signed request from a peer node, allow them to see unpublished streams + viewer = streamer + } + } body, err := io.ReadAll(r) if err != nil { return nil, echo.NewHTTPError(http.StatusBadRequest, "error reading body", err) } offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} + if streamer == viewer { + // this user gets sent right to the origin in case we're unpublished + origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(streamer) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) + } + log.Warn(ctx, "origin", "origin", origin) + myDID := s.cli.ServerDID() + if origin != nil && origin.ServerDID != myDID { + log.Warn(ctx, "proxying to origin", "origin", origin.ServerDID, "myDID", myDID) + token, err := CreateServiceToken(s.cli.ServiceAuthKey, s.cli.ServerDID()) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error creating service token", err) + } + parsedOrigin := origin.ServerDID + if len(parsedOrigin) > len("did:web:") && parsedOrigin[:len("did:web:")] == "did:web:" { + parsedOrigin = parsedOrigin[len("did:web:"):] + } + url := "https://" + parsedOrigin + "/xrpc/place.stream.playback.whep?rendition=" + rendition + "&streamer=" + streamer + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader([]byte(offer.SDP))) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to construct origin request", err) + } + req.Header.Set("Content-Type", _contentType) + req.Header.Set("X-Streamplace-Service-Auth", token) + resp, err := aqhttp.Client.Do(req) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "error proxying to origin", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, echo.NewHTTPError(resp.StatusCode, "origin error: "+string(body)) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error reading origin response", err) + } + return bytes.NewReader(data), nil + } + } answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer, viewer) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "error playing back", err) diff --git a/pkg/spxrpc/service_auth.go b/pkg/spxrpc/service_auth.go new file mode 100644 index 00000000..5fbb33fc --- /dev/null +++ b/pkg/spxrpc/service_auth.go @@ -0,0 +1,100 @@ +package spxrpc + +import ( + "context" + "fmt" + "time" + + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "stream.place/streamplace/pkg/log" +) + +const serviceAuthHeader = "X-Streamplace-Service-Auth" +const serviceTokenLifetime = 5 * time.Minute + +// ServiceIdentity represents an authenticated peer node in the same station. +type ServiceIdentity struct { + DID string +} + +type serviceAuthContextKeyType struct{} + +var serviceAuthContextKey = serviceAuthContextKeyType{} + +// GetServiceAuth returns the authenticated service identity from the context, +// or nil if the request did not come from an authenticated peer node. +func GetServiceAuth(ctx context.Context) *ServiceIdentity { + v := ctx.Value(serviceAuthContextKey) + if v == nil { + return nil + } + identity, ok := v.(*ServiceIdentity) + if !ok { + return nil + } + return identity +} + +// ServiceAuthMiddleware checks for a service-to-service JWT in the +// X-Streamplace-Service-Auth header. If present and valid, it populates +// the context with the caller's ServiceIdentity. If absent or invalid, +// the request passes through unchanged for the normal OAuth flow. +func (s *Server) ServiceAuthMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + tokenStr := c.Request().Header.Get(serviceAuthHeader) + if tokenStr == "" { + return next(c) + } + + ctx := c.Request().Context() + key := s.cli.ServiceAuthKey + if key == nil { + log.Warn(ctx, "service auth token present but no service auth key configured") + return next(c) + } + + token, err := jwt.Parse([]byte(tokenStr), jwt.WithKey(jwa.HS256, key), jwt.WithValidate(true)) + if err != nil { + log.Warn(ctx, "invalid service auth token", "error", err) + return next(c) + } + + issuer := token.Issuer() + if issuer == "" { + log.Warn(ctx, "service auth token missing issuer claim") + return next(c) + } + + identity := &ServiceIdentity{DID: issuer} + ctx = context.WithValue(ctx, serviceAuthContextKey, identity) + c.SetRequest(c.Request().WithContext(ctx)) + log.Warn(ctx, "authenticated service request", "service_did", issuer) + return next(c) + } + } +} + +// CreateServiceToken generates a signed JWT for authenticating this node +// to another node in the same station. +func CreateServiceToken(key jwk.Key, serverDID string) (string, error) { + now := time.Now() + token, err := jwt.NewBuilder(). + Issuer(serverDID). + IssuedAt(now). + Expiration(now.Add(serviceTokenLifetime)). + Build() + if err != nil { + return "", fmt.Errorf("failed to build service token: %w", err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, key)) + if err != nil { + return "", fmt.Errorf("failed to sign service token: %w", err) + } + + return string(signed), nil +} diff --git a/pkg/spxrpc/spxrpc.go b/pkg/spxrpc/spxrpc.go index 8265630d..b2791075 100644 --- a/pkg/spxrpc/spxrpc.go +++ b/pkg/spxrpc/spxrpc.go @@ -57,6 +57,7 @@ func NewServer(ctx context.Context, cli *config.CLI, model model.Model, stateful e.Use(s.ErrorHandlingMiddleware()) e.Use(s.ContextPreservingMiddleware()) e.Use(echomiddleware.Handler("", mdlw)) + e.Use(s.ServiceAuthMiddleware()) e.Use(op.OAuthMiddleware) err := s.RegisterHandlersPlaceStream(e) if err != nil { diff --git a/pkg/statedb/service_auth.go b/pkg/statedb/service_auth.go new file mode 100644 index 00000000..c3d30ab3 --- /dev/null +++ b/pkg/statedb/service_auth.go @@ -0,0 +1,52 @@ +package statedb + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + + "github.com/lestrrat-go/jwx/v2/jwk" + "stream.place/streamplace/pkg/log" +) + +// EnsureServiceAuthKey ensures a shared symmetric key exists in the config table +// for intra-service JWT authentication. All nodes sharing the same database will +// use the same key, enabling mutual authentication within a station. +func (state *StatefulDB) EnsureServiceAuthKey(ctx context.Context) (jwk.Key, error) { + conf, err := state.GetConfig("service-auth-key") + if err != nil { + return nil, fmt.Errorf("failed to get service auth key: %w", err) + } + + if conf != nil { + key, err := jwk.ParseKey(conf.Value) + if err != nil { + return nil, fmt.Errorf("failed to parse service auth key: %w", err) + } + return key, nil + } + + log.Warn(ctx, "no service auth key found, generating new one") + + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate random bytes: %w", err) + } + + key, err := jwk.FromRaw(secret) + if err != nil { + return nil, fmt.Errorf("failed to create symmetric key: %w", err) + } + + b, err := json.Marshal(key) + if err != nil { + return nil, fmt.Errorf("failed to marshal service auth key: %w", err) + } + + if err := state.PutConfig("service-auth-key", b); err != nil { + return nil, fmt.Errorf("failed to save service auth key: %w", err) + } + + return key, nil +} -- 2.51.2 From 1742699a8f357c2ff56b0d899e8fdab8dfc606c1 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 23 Feb 2026 17:26:51 -0800 Subject: [PATCH 36/49] spxrpc: helper function for service auth proxying --- pkg/spxrpc/place_stream_playback.go | 33 +++--------------- pkg/spxrpc/service_auth.go | 52 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go index 1ad5223b..d64529a4 100644 --- a/pkg/spxrpc/place_stream_playback.go +++ b/pkg/spxrpc/place_stream_playback.go @@ -5,11 +5,11 @@ import ( "context" "io" "net/http" + "net/url" "github.com/labstack/echo/v4" "github.com/pion/webrtc/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" - "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/log" ) @@ -52,34 +52,11 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st log.Warn(ctx, "origin", "origin", origin) myDID := s.cli.ServerDID() if origin != nil && origin.ServerDID != myDID { - log.Warn(ctx, "proxying to origin", "origin", origin.ServerDID, "myDID", myDID) - token, err := CreateServiceToken(s.cli.ServiceAuthKey, s.cli.ServerDID()) + data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "POST", "place.stream.playback.whep", + url.Values{"rendition": {rendition}, "streamer": {streamer}}, + bytes.NewReader([]byte(offer.SDP)), _contentType) if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, "error creating service token", err) - } - parsedOrigin := origin.ServerDID - if len(parsedOrigin) > len("did:web:") && parsedOrigin[:len("did:web:")] == "did:web:" { - parsedOrigin = parsedOrigin[len("did:web:"):] - } - url := "https://" + parsedOrigin + "/xrpc/place.stream.playback.whep?rendition=" + rendition + "&streamer=" + streamer - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader([]byte(offer.SDP))) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to construct origin request", err) - } - req.Header.Set("Content-Type", _contentType) - req.Header.Set("X-Streamplace-Service-Auth", token) - resp, err := aqhttp.Client.Do(req) - if err != nil { - return nil, echo.NewHTTPError(http.StatusBadGateway, "error proxying to origin", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, echo.NewHTTPError(resp.StatusCode, "origin error: "+string(body)) - } - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, "error reading origin response", err) + return nil, err } return bytes.NewReader(data), nil } diff --git a/pkg/spxrpc/service_auth.go b/pkg/spxrpc/service_auth.go index 5fbb33fc..ffcc7602 100644 --- a/pkg/spxrpc/service_auth.go +++ b/pkg/spxrpc/service_auth.go @@ -3,12 +3,17 @@ package spxrpc import ( "context" "fmt" + "io" + "net/http" + "net/url" + "strings" "time" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" + "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/log" ) @@ -98,3 +103,50 @@ func CreateServiceToken(key jwk.Key, serverDID string) (string, error) { return string(signed), nil } + +// DIDWebToHost extracts the hostname from a did:web DID. +func DIDWebToHost(did string) string { + return strings.TrimPrefix(did, "did:web:") +} + +// ProxyServiceRequest proxies an XRPC request to a peer node, authenticating +// with a service token. Returns the response body bytes or an echo.HTTPError. +func (s *Server) ProxyServiceRequest(ctx context.Context, targetDID, httpMethod, xrpcMethod string, query url.Values, body io.Reader, contentType string) ([]byte, error) { + token, err := CreateServiceToken(s.cli.ServiceAuthKey, s.cli.ServerDID()) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error creating service token: "+err.Error()) + } + + u := url.URL{ + Scheme: "https", + Host: DIDWebToHost(targetDID), + Path: "/xrpc/" + xrpcMethod, + RawQuery: query.Encode(), + } + + log.Warn(ctx, "proxying service request", "target", targetDID, "method", xrpcMethod) + + req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), body) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to construct proxy request: "+err.Error()) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set(serviceAuthHeader, token) + + resp, err := aqhttp.Client.Do(req) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "error proxying to peer: "+err.Error()) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error reading peer response: "+err.Error()) + } + if resp.StatusCode != http.StatusOK { + return nil, echo.NewHTTPError(resp.StatusCode, "peer error: "+string(data)) + } + return data, nil +} -- 2.51.2 From 500f032fdfd5ea6cce4324df0b82fbb10ccbc05d Mon Sep 17 00:00:00 2001 From: natalie <22222885+espeon@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:39:41 -0600 Subject: [PATCH 37/49] edit for consistency --- js/components/locales/ro-RO/common.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/components/locales/ro-RO/common.ftl b/js/components/locales/ro-RO/common.ftl index db4e0ccb..9feffe53 100644 --- a/js/components/locales/ro-RO/common.ftl +++ b/js/components/locales/ro-RO/common.ftl @@ -62,7 +62,7 @@ viewer-count = { $count -> } ## PDS Host Selector -pds-selector-title = Nou în Atmosferă? +pds-selector-title = Nou pe Atmosphere? pds-selector-description = Va trebui să selectați un PDS (Personal Data Server) pentru a accesa aplicațiile de pe Atmosphere, cum ar fi Bluesky, Tangled și Spark. pds-selector-custom-label = Alt PDS pds-selector-custom-description = Introduceți adresa URL a propriei gazde PDS -- 2.51.2 From bcc253f5166a68e7a12daf625f2cceb42ca91b5f Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 23 Feb 2026 17:43:55 -0800 Subject: [PATCH 38/49] syndication: don't syndicate unpublished streams --- pkg/spxrpc/place_stream_live.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/spxrpc/place_stream_live.go b/pkg/spxrpc/place_stream_live.go index 9d6e0169..e1563c99 100644 --- a/pkg/spxrpc/place_stream_live.go +++ b/pkg/spxrpc/place_stream_live.go @@ -193,6 +193,9 @@ func (s *Server) handlePlaceStreamLiveSubscribeSegments(c echo.Context) error { log.Debug(ctx, "exiting segment reader") return case file := <-segChan.C: + if !file.Published { + continue + } log.Debug(ctx, "got segment", "file", file.Filepath) err := ws.WriteMessage(websocket.BinaryMessage, file.Data) if err != nil { -- 2.51.2 From 3816f764dc86f28855c2ce90f6ec6edd1e62899f Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 23 Feb 2026 17:54:32 -0800 Subject: [PATCH 39/49] websocket: random nil pointer i just found lol --- pkg/api/websocket.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/api/websocket.go b/pkg/api/websocket.go index 6373111f..55287c22 100644 --- a/pkg/api/websocket.go +++ b/pkg/api/websocket.go @@ -253,6 +253,7 @@ func (a *StreamplaceAPI) HandleWebsocket(ctx context.Context) httprouter.Handle tp := teleports[0] if tp.Repo == nil { log.Error(ctx, "teleportee repo is nil", "uri", tp.URI) + return } viewerCount := a.Bus.GetViewerCount(tp.RepoDID) arrivalMsg := streamplace.Livestream_TeleportArrival{ -- 2.51.2 From 3d4617405a68d596100999541de8a3172f8e1c4d Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 24 Feb 2026 12:03:44 -0800 Subject: [PATCH 40/49] spxrpc: proxy getSegments requests too --- pkg/spxrpc/place_stream_live.go | 39 ++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/spxrpc/place_stream_live.go b/pkg/spxrpc/place_stream_live.go index e1563c99..3637aecb 100644 --- a/pkg/spxrpc/place_stream_live.go +++ b/pkg/spxrpc/place_stream_live.go @@ -2,8 +2,11 @@ package spxrpc import ( "context" + "encoding/json" "fmt" "net/http" + "net/url" + "strconv" "time" "github.com/bluesky-social/indigo/lex/util" @@ -82,8 +85,42 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st beforeTime = &parsedTime } + includeUnpublished := false sess, _ := oatproxy.GetOAuthSession(ctx) - includeUnpublished := sess != nil && sess.DID == userDID + if sess != nil { + includeUnpublished = sess.DID == userDID + log.Warn(ctx, "oauth session present", "session_did", sess.DID, "user_did", userDID, "include_unpublished", includeUnpublished) + // this user gets sent right to the origin in case we're unpublished + origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(sess.DID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) + } + myDID := s.cli.ServerDID() + if origin != nil && origin.ServerDID != myDID { + data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "GET", "place.stream.live.getSegments", + url.Values{"userDID": {userDID}, "limit": {strconv.Itoa(limit)}, "before": {before}}, + nil, "application/json") + if err != nil { + return nil, fmt.Errorf("error proxying to peer: %w", err) + } + var output placestreamtypes.LiveGetSegments_Output + err = json.Unmarshal(data, &output) + if err != nil { + return nil, fmt.Errorf("error unmarshalling response: %w", err) + } + return &output, nil + } else { + log.Warn(ctx, "origin is local", "origin", origin) + } + } else { + log.Warn(ctx, "no oauth session") + svc := GetServiceAuth(ctx) + if svc != nil { + log.Warn(ctx, "service auth present", "service_did", svc.DID) + // this is a signed request from a peer node, allow them to see unpublished streams + includeUnpublished = true + } + } segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, includeUnpublished, beforeTime, nil) if err != nil { -- 2.51.2 From 5253b18906fde5d15b0910ac29a9b653028ad57d Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 24 Feb 2026 12:37:54 -0800 Subject: [PATCH 41/49] prelive: cleanup from code review --- pkg/atproto/.gitignore | 2 - pkg/constants/constants.go | 5 -- pkg/director/director.go | 9 ++-- pkg/director/stream_session.go | 78 ++++++++++++++--------------- pkg/spxrpc/place_stream_live.go | 13 ++--- pkg/spxrpc/place_stream_playback.go | 3 -- pkg/spxrpc/service_auth.go | 4 +- pkg/statedb/queue_processor.go | 6 +-- 8 files changed, 52 insertions(+), 68 deletions(-) delete mode 100644 pkg/atproto/.gitignore diff --git a/pkg/atproto/.gitignore b/pkg/atproto/.gitignore deleted file mode 100644 index c69c88c1..00000000 --- a/pkg/atproto/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -test.jpg -test_scumbag.jpg diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index a8c70337..ce758d8c 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1,7 +1,5 @@ package constants -import "time" - var PLACE_STREAM_KEY = "place.stream.key" //nolint:all var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all @@ -73,6 +71,3 @@ const ( ) const BlueskyProfileGoliveKey = "place.stream.live.golive" - -const LivestreamInactiveCheckInterval = 90 * time.Second -const LivestreamConsideredInactiveAfter = 60 * time.Second diff --git a/pkg/director/director.go b/pkg/director/director.go index 129c5599..b6966a9c 100644 --- a/pkg/director/director.go +++ b/pkg/director/director.go @@ -83,10 +83,11 @@ func (d *Director) Start(ctx context.Context) error { statefulDB: d.statefulDB, replicator: d.replicator, // Initialize notification channels (buffered size 1 for coalescing) - statusUpdateChan: make(chan struct{}, 1), - originUpdateChan: make(chan struct{}, 1), - localDB: d.localDB, - atsync: d.atsync, + statusUpdateChan: make(chan struct{}, 1), + originUpdateChan: make(chan struct{}, 1), + livestreamUpdateChan: make(chan struct{}, 1), + localDB: d.localDB, + atsync: d.atsync, } d.streamSessions[not.Segment.RepoDID] = ss g.Go(func() error { diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 324da01c..62f5eba0 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "net/url" - "sync" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -51,8 +50,9 @@ type StreamSession struct { localDB localdb.LocalDB // Channels for background workers - statusUpdateChan chan struct{} // Signal to update status - originUpdateChan chan struct{} // Signal to update broadcast origin + statusUpdateChan chan struct{} // Signal to update status + originUpdateChan chan struct{} // Signal to update broadcast origin + livestreamUpdateChan chan struct{} // Signal to update livestream g *errgroup.Group started chan struct{} @@ -62,7 +62,6 @@ type StreamSession struct { replicator replication.Replicator atsync *atproto.ATProtoSynchronizer - lastLivestreamLock sync.Mutex lastLivestreamTime time.Time } @@ -104,31 +103,18 @@ func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotif allRenditions = append([]renditions.Rendition{sourceRendition}, allRenditions...) ss.hls = media.NewM3U8(allRenditions) - // for _, r := range allRenditions { - // g.Go(func() error { - // for { - // if ctx.Err() != nil { - // return nil - // } - // err := ss.mm.ToHLS(ctx, spseg.Creator, r.Name, ss.hls) - // if ctx.Err() != nil { - // return nil - // } - // log.Warn(ctx, "hls failed, retrying in 5 seconds", "error", err) - // time.Sleep(time.Second * 5) - // } - // }) - // } - close(ss.started) - // Start background workers for status and origin updates + // Start background workers for status, origin, and livestream updates ss.g.Go(func() error { return ss.statusUpdateLoop(ctx, spseg.Creator) }) ss.g.Go(func() error { return ss.originUpdateLoop(ctx) }) + ss.g.Go(func() error { + return ss.livestreamUpdateLoop(ctx, spseg.Creator) + }) if notif.Local { ss.Go(ctx, func() error { @@ -136,15 +122,6 @@ func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotif }) } - // ss.Go(ctx, func() error { - // err := ss.UpdateProfilePicture(ctx, spseg.Creator) - // if err != nil { - // log.Error(ctx, "failed to update profile picture", "error", err) - // } - // <-ctx.Done() - // return nil - // }) - for { select { case <-ss.segmentChan: @@ -232,9 +209,7 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment if notif.Local { ss.UpdateStatus(ctx, spseg.Creator) ss.UpdateBroadcastOrigin(ctx) - ss.Go(ctx, func() error { - return ss.UpdateLivestream(ctx, spseg.Creator) - }) + ss.UpdateLivestream(ctx, spseg.Creator) } if ss.cli.LivepeerGatewayURL != "" { @@ -485,14 +460,37 @@ func (ss *StreamSession) doUpdateStatus(ctx context.Context, repoDID string) err var livestreamUpdateInterval = time.Second * 30 -func (ss *StreamSession) UpdateLivestream(ctx context.Context, repoDID string) error { - ctx = log.WithLogValues(ctx, "func", "UpdateLivestream") - ss.lastLivestreamLock.Lock() - defer ss.lastLivestreamLock.Unlock() - if time.Since(ss.lastLivestreamTime) < livestreamUpdateInterval { - log.Debug(ctx, "not updating livestream, last livestream was less than 30 seconds ago") - return nil +// UpdateLivestream signals the background worker to update the livestream record (non-blocking) +func (ss *StreamSession) UpdateLivestream(ctx context.Context, repoDID string) { + select { + case ss.livestreamUpdateChan <- struct{}{}: + default: + // Channel full, signal already pending + } +} + +// livestreamUpdateLoop runs as a background goroutine for the session lifetime +func (ss *StreamSession) livestreamUpdateLoop(ctx context.Context, repoDID string) error { + ctx = log.WithLogValues(ctx, "func", "livestreamUpdateLoop") + for { + select { + case <-ctx.Done(): + return nil + case <-ss.livestreamUpdateChan: + if time.Since(ss.lastLivestreamTime) < livestreamUpdateInterval { + log.Debug(ctx, "not updating livestream, last livestream was less than 30 seconds ago") + continue + } + if err := ss.doUpdateLivestream(ctx, repoDID); err != nil { + log.Error(ctx, "failed to update livestream", "error", err) + } + } } +} + +// doUpdateLivestream performs the actual livestream record update work +func (ss *StreamSession) doUpdateLivestream(ctx context.Context, repoDID string) error { + ctx = log.WithLogValues(ctx, "func", "doUpdateLivestream") lastLivestream, err := ss.mod.GetLatestLivestreamForRepo(repoDID) if err != nil { diff --git a/pkg/spxrpc/place_stream_live.go b/pkg/spxrpc/place_stream_live.go index 3637aecb..9e296bbd 100644 --- a/pkg/spxrpc/place_stream_live.go +++ b/pkg/spxrpc/place_stream_live.go @@ -87,16 +87,15 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st includeUnpublished := false sess, _ := oatproxy.GetOAuthSession(ctx) - if sess != nil { - includeUnpublished = sess.DID == userDID - log.Warn(ctx, "oauth session present", "session_did", sess.DID, "user_did", userDID, "include_unpublished", includeUnpublished) + if sess != nil && sess.DID == userDID { + includeUnpublished = true // this user gets sent right to the origin in case we're unpublished origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(sess.DID) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) } - myDID := s.cli.ServerDID() - if origin != nil && origin.ServerDID != myDID { + myServerDID := s.cli.ServerDID() + if origin != nil && origin.ServerDID != myServerDID { data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "GET", "place.stream.live.getSegments", url.Values{"userDID": {userDID}, "limit": {strconv.Itoa(limit)}, "before": {before}}, nil, "application/json") @@ -109,14 +108,10 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &output, nil - } else { - log.Warn(ctx, "origin is local", "origin", origin) } } else { - log.Warn(ctx, "no oauth session") svc := GetServiceAuth(ctx) if svc != nil { - log.Warn(ctx, "service auth present", "service_did", svc.DID) // this is a signed request from a peer node, allow them to see unpublished streams includeUnpublished = true } diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go index d64529a4..05b08a78 100644 --- a/pkg/spxrpc/place_stream_playback.go +++ b/pkg/spxrpc/place_stream_playback.go @@ -10,7 +10,6 @@ import ( "github.com/labstack/echo/v4" "github.com/pion/webrtc/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" - "stream.place/streamplace/pkg/log" ) func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { @@ -33,7 +32,6 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st } else { svc := GetServiceAuth(ctx) if svc != nil { - log.Warn(ctx, "service auth present", "service_did", svc.DID) // this is a signed request from a peer node, allow them to see unpublished streams viewer = streamer } @@ -49,7 +47,6 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) } - log.Warn(ctx, "origin", "origin", origin) myDID := s.cli.ServerDID() if origin != nil && origin.ServerDID != myDID { data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "POST", "place.stream.playback.whep", diff --git a/pkg/spxrpc/service_auth.go b/pkg/spxrpc/service_auth.go index ffcc7602..9cb5a636 100644 --- a/pkg/spxrpc/service_auth.go +++ b/pkg/spxrpc/service_auth.go @@ -77,7 +77,7 @@ func (s *Server) ServiceAuthMiddleware() echo.MiddlewareFunc { identity := &ServiceIdentity{DID: issuer} ctx = context.WithValue(ctx, serviceAuthContextKey, identity) c.SetRequest(c.Request().WithContext(ctx)) - log.Warn(ctx, "authenticated service request", "service_did", issuer) + log.Log(ctx, "got authenticated service request", "service_did", issuer) return next(c) } } @@ -124,7 +124,7 @@ func (s *Server) ProxyServiceRequest(ctx context.Context, targetDID, httpMethod, RawQuery: query.Encode(), } - log.Warn(ctx, "proxying service request", "target", targetDID, "method", xrpcMethod) + log.Log(ctx, "proxying service request", "target", targetDID, "method", xrpcMethod) req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), body) if err != nil { diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 54d0892a..858fe23c 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -105,11 +105,11 @@ func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task return fmt.Errorf("could not parse last seen at: %w", err) } if rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 { - log.Warn(ctx, "livestream has no idle timeout, skipping finalization", "uri", livestream.URI) + log.Debug(ctx, "livestream has no idle timeout, skipping finalization", "uri", livestream.URI) return nil } if time.Since(lastSeenTime) < (time.Duration(*rec.IdleTimeoutSeconds) * time.Second) { - log.Warn(ctx, "livestream is active, skipping finalization", "lastSeenAt", lastSeenTime) + log.Debug(ctx, "livestream is active, skipping finalization", "lastSeenAt", lastSeenTime) return nil } session, err := state.GetSessionByDID(livestream.RepoDID) @@ -125,7 +125,7 @@ func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task return fmt.Errorf("failed to get xrpc client: %w", err) } if rec.EndedAt != nil { - log.Warn(ctx, "livestream has already ended, skipping", "uri", livestream.URI, "endedAt", *rec.EndedAt) + log.Debug(ctx, "livestream has already ended, skipping", "uri", livestream.URI, "endedAt", *rec.EndedAt) return nil } -- 2.51.2 From 3609b338488b7f4ae7ab6c9151563b0405da5977 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 24 Feb 2026 13:26:00 -0800 Subject: [PATCH 42/49] weird merge stuff? --- js/app/components/live-dashboard/livestream-panel.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index 29384867..f733fc24 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -18,7 +18,6 @@ import { useUrl, zero, } from "@streamplace/components"; -import { error } from "console"; import { ImagePlus, X } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -379,12 +378,9 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { if (file) { setSelectedImage(file); } - console.error("Failed to fetch last image:", error); - toast.show("Error", "Failed to load previous thumbnail", { - duration: 3, - }); }; - }, [livestream, toast]); + input.click(); + }, []); const handleImageRemove = useCallback(() => { setSelectedImage(undefined); -- 2.51.2 From 7e89f2fb54ed3cfb84ff34499152526641ce9d35 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 24 Feb 2026 13:39:41 -0800 Subject: [PATCH 43/49] seriously fuck off --- js/components/src/components/mobile-player/use-webrtc.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/js/components/src/components/mobile-player/use-webrtc.tsx b/js/components/src/components/mobile-player/use-webrtc.tsx index 787fa97c..e62c497d 100644 --- a/js/components/src/components/mobile-player/use-webrtc.tsx +++ b/js/components/src/components/mobile-player/use-webrtc.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; import * as sdpTransform from "sdp-transform"; -import { text } from "stream/consumers"; import { StreamplaceAgent } from "streamplace"; import { PlayerStatus, @@ -223,7 +222,7 @@ export async function negotiateIngestConnectionWithClientOffer( ); return "https://stream.place/example"; } else { - console.error(text); + console.error(await response.text()); } } catch (e) { console.error(`posting sdp offer failed: ${e}`); -- 2.51.2 From e3e3c514a9bfeee3895c2963eb76412a27a5f784 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 25 Feb 2026 13:00:57 -0800 Subject: [PATCH 44/49] fixes for self-test --- js/desktop/src/tests/server-restart-test.ts | 3 +++ js/desktop/src/tests/test-runner.ts | 1 + pkg/api/api.go | 6 +++++- pkg/api/websocket.go | 4 ++++ pkg/director/stream_session.go | 8 ++++++-- pkg/media/manifest_builder.go | 9 ++++++++- pkg/media/media_signer.go | 2 +- pkg/model/livestream.go | 3 +++ pkg/spxrpc/app_bsky_feed.go | 4 ++++ pkg/spxrpc/place_stream_playback.go | 17 ++++++++++++----- pkg/spxrpc/spxrpc.go | 4 +++- 11 files changed, 50 insertions(+), 11 deletions(-) diff --git a/js/desktop/src/tests/server-restart-test.ts b/js/desktop/src/tests/server-restart-test.ts index 1f85c118..7d5df7de 100644 --- a/js/desktop/src/tests/server-restart-test.ts +++ b/js/desktop/src/tests/server-restart-test.ts @@ -29,8 +29,11 @@ export const serverRestartTest: E2ETest = { const env = { SP_HTTP_ADDR: `127.0.0.1:${randomPort()}`, SP_HTTP_INTERNAL_ADDR: `127.0.0.1:${randomPort()}`, + SP_RTMP_ADDR: `127.0.0.1:${randomPort()}`, SP_DATA_DIR: tmpDir, SP_TEST_STREAM: "true", + SP_NO_FIREHOSE: "true", + SP_WIDE_OPEN: "true", }; let { proc } = await makeNode({ env: env, diff --git a/js/desktop/src/tests/test-runner.ts b/js/desktop/src/tests/test-runner.ts index 6e055385..157b182e 100644 --- a/js/desktop/src/tests/test-runner.ts +++ b/js/desktop/src/tests/test-runner.ts @@ -71,6 +71,7 @@ export default async function runTests( SP_HTTP_INTERNAL_ADDR: `127.0.0.1:${randomPort()}`, SP_RTMP_ADDR: `127.0.0.1:${randomPort()}`, SP_DATA_DIR: tmpDir, + SP_NO_FIREHOSE: "true", }; } if (test.setup) { diff --git a/pkg/api/api.go b/pkg/api/api.go index ddf2d3ee..b77aec72 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -155,7 +155,7 @@ func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { Recorder: metrics.NewRecorder(metrics.Config{}), }) var xrpc http.Handler - xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB, a.MediaManager) + xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB, a.MediaManager, a.Aliases) if err != nil { return nil, err } @@ -662,6 +662,10 @@ func (a *StreamplaceAPI) HandleLivestream(ctx context.Context) httprouter.Handle apierrors.WriteHTTPInternalServerError(w, "could not get livestream", err) return } + if livestream == nil { + apierrors.WriteHTTPNotFound(w, "no livestream found", nil) + return + } doc, err := livestream.ToLivestreamView() if err != nil { diff --git a/pkg/api/websocket.go b/pkg/api/websocket.go index 55287c22..ad8246dd 100644 --- a/pkg/api/websocket.go +++ b/pkg/api/websocket.go @@ -218,6 +218,10 @@ func (a *StreamplaceAPI) HandleWebsocket(ctx context.Context) httprouter.Handle log.Error(ctx, "could not get latest livestream", "error", err) return } + if ls == nil { + log.Error(ctx, "no livestream found", "repoDID", repoDID) + return + } lsv, err := ls.ToLivestreamView() if err != nil { log.Error(ctx, "could not marshal livestream", "error", err) diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 62f5eba0..e1746063 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -50,8 +50,8 @@ type StreamSession struct { localDB localdb.LocalDB // Channels for background workers - statusUpdateChan chan struct{} // Signal to update status - originUpdateChan chan struct{} // Signal to update broadcast origin + statusUpdateChan chan struct{} // Signal to update status + originUpdateChan chan struct{} // Signal to update broadcast origin livestreamUpdateChan chan struct{} // Signal to update livestream g *errgroup.Group @@ -369,6 +369,10 @@ func (ss *StreamSession) doUpdateStatus(ctx context.Context, repoDID string) err if err != nil { return fmt.Errorf("could not get latest livestream for repoDID: %w", err) } + if ls == nil { + log.Debug(ctx, "no livestream found, skipping status update", "repoDID", repoDID) + return nil + } lsv, err := ls.ToLivestreamView() if err != nil { return fmt.Errorf("could not convert livestream to streamplace livestream: %w", err) diff --git a/pkg/media/manifest_builder.go b/pkg/media/manifest_builder.go index 1740798c..3c32fc46 100644 --- a/pkg/media/manifest_builder.go +++ b/pkg/media/manifest_builder.go @@ -6,6 +6,7 @@ import ( "fmt" "stream.place/streamplace/pkg/aqtime" + "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/model" @@ -24,11 +25,13 @@ import ( // See https://iptc.org/std/videometadatahub/recommendation/IPTC-VideoMetadataHub-props-Rec_1.6.html type ManifestBuilder struct { model model.Model + cli *config.CLI } -func NewManifestBuilder(model model.Model) *ManifestBuilder { +func NewManifestBuilder(model model.Model, cli *config.CLI) *ManifestBuilder { return &ManifestBuilder{ model: model, + cli: cli, } } @@ -82,6 +85,10 @@ func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName strin livestreamTitle = ls.Title shouldPublish = ls.EndedAt == nil } + // for testing only + if mb.cli.WideOpen { + shouldPublish = true + } // Start with base manifest startTime := aqtime.FromMillis(start).String() diff --git a/pkg/media/media_signer.go b/pkg/media/media_signer.go index 66ed5e46..0c4bf184 100644 --- a/pkg/media/media_signer.go +++ b/pkg/media/media_signer.go @@ -78,7 +78,7 @@ func MakeMediaSigner(ctx context.Context, cli *config.CLI, streamer string, sign TAURL: cli.TAURL, AQPub: pub, did: did.DIDKey(), - manifestBuilder: NewManifestBuilder(model), + manifestBuilder: NewManifestBuilder(model, cli), }, nil } diff --git a/pkg/model/livestream.go b/pkg/model/livestream.go index f2a245e7..0c918b80 100644 --- a/pkg/model/livestream.go +++ b/pkg/model/livestream.go @@ -77,6 +77,9 @@ func (m *DBModel) GetLatestLivestreamForRepo(repoDID string) (*Livestream, error Where("repo_did = ?", repoDID). Order("created_at DESC"). First(&livestream).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } if err != nil { return nil, fmt.Errorf("error retrieving latest livestream: %w", err) } diff --git a/pkg/spxrpc/app_bsky_feed.go b/pkg/spxrpc/app_bsky_feed.go index 4a3d258f..9f8dd4a0 100644 --- a/pkg/spxrpc/app_bsky_feed.go +++ b/pkg/spxrpc/app_bsky_feed.go @@ -66,6 +66,10 @@ func (s *Server) handleAppBskyFeedGetFeedSkeleton(ctx context.Context, inCursor log.Error(ctx, "failed to get latest livestream, skipping", "repoDID", seg.RepoDID, "error", err) continue } + if ls == nil { + log.Error(ctx, "no livestream found, skipping", "repoDID", seg.RepoDID) + continue + } if ls.PostURI != "" { posts = append(posts, model.FeedPost{ URI: ls.PostURI, diff --git a/pkg/spxrpc/place_stream_playback.go b/pkg/spxrpc/place_stream_playback.go index 05b08a78..1110c0eb 100644 --- a/pkg/spxrpc/place_stream_playback.go +++ b/pkg/spxrpc/place_stream_playback.go @@ -6,13 +6,18 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/labstack/echo/v4" "github.com/pion/webrtc/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" + "stream.place/streamplace/pkg/constants" ) func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { + if alias, ok := s.aliases[streamer]; ok { + streamer = alias + } if streamer == "" { return nil, echo.NewHTTPError(http.StatusBadRequest, "streamer is required") @@ -21,11 +26,13 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st return nil, echo.NewHTTPError(http.StatusBadRequest, "rendition is required") } viewer := "" - repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) - if err != nil { - return nil, err + if !s.cli.WideOpen && !strings.HasPrefix(streamer, constants.DID_KEY_PREFIX) { + repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) + if err != nil { + return nil, err + } + streamer = repo.DID } - streamer = repo.DID session, _ := oatproxy.GetOAuthSession(ctx) if session != nil { viewer = session.DID @@ -58,7 +65,7 @@ func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition st return bytes.NewReader(data), nil } } - answer, err := s.mm.WebRTCPlayback2(ctx, repo.DID, rendition, &offer, viewer) + answer, err := s.mm.WebRTCPlayback2(ctx, streamer, rendition, &offer, viewer) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "error playing back", err) } diff --git a/pkg/spxrpc/spxrpc.go b/pkg/spxrpc/spxrpc.go index b2791075..ccd37188 100644 --- a/pkg/spxrpc/spxrpc.go +++ b/pkg/spxrpc/spxrpc.go @@ -37,9 +37,10 @@ type Server struct { op *oatproxy.OATProxy localDB localdb.LocalDB mm *media.MediaManager + aliases map[string]string } -func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB, mm *media.MediaManager) (*Server, error) { +func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB, mm *media.MediaManager, aliases map[string]string) (*Server, error) { e := echo.New() s := &Server{ e: e, @@ -53,6 +54,7 @@ func NewServer(ctx context.Context, cli *config.CLI, model model.Model, stateful op: op, localDB: ldb, mm: mm, + aliases: aliases, } e.Use(s.ErrorHandlingMiddleware()) e.Use(s.ContextPreservingMiddleware()) -- 2.51.2 From 78a4857cc160c933dfc43fd4583ee5f2d0072c4b Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 25 Feb 2026 15:04:02 -0800 Subject: [PATCH 45/49] mobile: move to always-on streaming model --- .../live-dashboard/stream-monitor.tsx | 2 +- js/app/components/mobile/desktop-ui.tsx | 8 +- js/app/components/mobile/ui.tsx | 29 ++---- js/app/hooks/useSegmentTiming.tsx | 88 ------------------ js/app/src/router.tsx | 2 +- .../src/components/dashboard/header.tsx | 2 +- .../src/components/mobile-player/ui/input.tsx | 6 +- .../components/mobile-player/ui/metrics.tsx | 19 +++- .../mobile-player/video-async.native.tsx | 12 ++- .../src/components/mobile-player/video.tsx | 5 +- js/components/src/hooks/useLivestreamInfo.ts | 25 ++--- js/components/src/hooks/useSegmentTiming.tsx | 9 +- .../src/player-store/player-state.tsx | 6 -- .../src/player-store/player-store.tsx | 6 +- .../player-store/single-player-provider.tsx | 4 - .../src/streamplace-store/stream.tsx | 92 +++++++++---------- pkg/api/playback.go | 24 +++++ pkg/director/stream_session.go | 10 +- 18 files changed, 128 insertions(+), 221 deletions(-) delete mode 100644 js/app/hooks/useSegmentTiming.tsx diff --git a/js/app/components/live-dashboard/stream-monitor.tsx b/js/app/components/live-dashboard/stream-monitor.tsx index 8c8d6e02..05d1529d 100644 --- a/js/app/components/live-dashboard/stream-monitor.tsx +++ b/js/app/components/live-dashboard/stream-monitor.tsx @@ -5,6 +5,7 @@ import { useLivestream, useLivestreamStore, usePlayerStore, + useSegmentTiming, zero, } from "@streamplace/components"; import { DesktopUi } from "components/mobile/desktop-ui"; @@ -14,7 +15,6 @@ import { useEffect, useState } from "react"; import { Image, TouchableOpacity, View } from "react-native"; import Animated from "react-native-reanimated"; import { useLiveUser } from "../../hooks/useLiveUser"; -import { useSegmentTiming } from "../../hooks/useSegmentTiming"; import StreamScreen from "./live-selector"; const { flex, bg, r, borders, layout, p, text, w, h, mt } = zero; diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx index 8cf2b6b1..689c5a7a 100644 --- a/js/app/components/mobile/desktop-ui.tsx +++ b/js/app/components/mobile/desktop-ui.tsx @@ -54,8 +54,6 @@ export function DesktopUi({ setShowCountdown, recordSubmitted, setRecordSubmitted, - ingestStarting, - setIngestStarting, toggleGoLive, } = useLivestreamInfo(); const { width, height } = usePlayerDimensions(); @@ -112,11 +110,8 @@ export function DesktopUi({ return () => { if (fadeTimeout.current) clearTimeout(fadeTimeout.current); - if (ingestStarting) { - setIngestStarting(false); - } }; - }, [ingestStarting, setIngestStarting, resetFadeTimer]); + }, [resetFadeTimer]); const animatedFadeStyle = useAnimatedStyle(() => ({ opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, @@ -252,7 +247,6 @@ export function DesktopUi({ diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index aa7aee33..c33d2bba 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -7,6 +7,7 @@ import { Toast, useAvatars, useCameraToggle, + useLivestream, useLivestreamInfo, useLivestreamStore, useMuted, @@ -69,8 +70,6 @@ export function MobileUi({ setShowCountdown, recordSubmitted, setRecordSubmitted, - ingestStarting, - setIngestStarting, toggleGoLive, toggleStopStream, } = useLivestreamInfo(); @@ -83,32 +82,19 @@ export function MobileUi({ const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); const muted = useMuted(); const setMuted = useSetMuted(); + const ls = useLivestream(); const { shouldShowFloatingMetrics, shouldShowChatSidePanel, chatPanelWidth } = useResponsiveLayout(); const [showLoading, setShowLoading] = useState(false); - useEffect(() => { - return () => { - if (ingestStarting) { - setIngestStarting(false); - } - }; - }, [ingestStarting, setIngestStarting]); - useEffect(() => { if (recordSubmitted) setShowLoading(false); }, [recordSubmitted]); - const isSelfAndNotLive = ingest === "new"; - const isLive = ingest !== null && ingest !== "new"; - - useEffect(() => { - if (isLive && ingestStarting) { - setIngestStarting(false); - } - }, [isLive, ingestStarting, setIngestStarting]); + const isSelfAndNotLive = ingest !== null && ls === null; + const isSelfAndLive = ingest !== null && ls !== null; const FADE_OUT_DELAY = 4000; const fadeOpacity = useSharedValue(1); @@ -225,7 +211,7 @@ export function MobileUi({ - {shouldShowFloatingMetrics && isLive && ( + {shouldShowFloatingMetrics && isSelfAndLive && ( )} @@ -281,7 +266,7 @@ export function MobileUi({ - {showChat === undefined && ingest !== "new" && ( + {showChat === undefined && !isSelfAndNotLive && ( )} diff --git a/js/app/hooks/useSegmentTiming.tsx b/js/app/hooks/useSegmentTiming.tsx deleted file mode 100644 index cd6a8e52..00000000 --- a/js/app/hooks/useSegmentTiming.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useLivestreamStore } from "@streamplace/components"; -import { useEffect, useRef, useState } from "react"; - -export type ConnectionQuality = "good" | "degraded" | "poor"; - -function getLiveConnectionQuality( - timeBetweenSegments: number | null, - range: number | null, - numOfSegments: number = 1, -): ConnectionQuality { - if (timeBetweenSegments === null || range === null) return "poor"; - - if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { - return "good"; - } - if (timeBetweenSegments <= 3000 && range <= (3000 * 60) / numOfSegments) { - return "degraded"; - } - return "poor"; -} - -export function useSegmentTiming() { - const latestSegment = useLivestreamStore((x) => x.segment); - const [segmentDeltas, setSegmentDeltas] = useState([]); - const prevSegmentRef = useRef(); - const prevTimestampRef = useRef(null); - - // Dummy state to force update every second - const [, setNow] = useState(Date.now()); - - useEffect(() => { - const interval = setInterval(() => { - setNow(Date.now()); - }, 1000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (latestSegment && prevSegmentRef.current !== latestSegment) { - const now = Date.now(); - if (prevTimestampRef.current !== null) { - const delta = now - prevTimestampRef.current; - // Only store the last 25 deltas - setSegmentDeltas((prev) => [...prev, delta].slice(-25)); - } - prevTimestampRef.current = now; - prevSegmentRef.current = latestSegment; - } - }, [latestSegment]); - - // The most recent time between segments - const timeBetweenSegments = - segmentDeltas.length > 0 - ? segmentDeltas[segmentDeltas.length - 1] - : prevTimestampRef.current - ? Date.now() - prevTimestampRef.current - : null; - - // Calculate mean and range of deltas - const mean = - segmentDeltas.length > 0 - ? Math.round( - segmentDeltas.reduce((acc, curr) => acc + curr, 0) / - segmentDeltas.length, - ) - : null; - - const range = - segmentDeltas.length > 0 - ? Math.max(...segmentDeltas) - Math.min(...segmentDeltas) - : null; - - let to_ret = { - segmentDeltas, - timeBetweenSegments, - mean, - range, - connectionQuality: "poor", - }; - - to_ret.connectionQuality = getLiveConnectionQuality( - timeBetweenSegments, - range, - segmentDeltas.length, - ); - - return to_ret; -} diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx index 055b7a4f..c69c71cc 100644 --- a/js/app/src/router.tsx +++ b/js/app/src/router.tsx @@ -542,7 +542,7 @@ export function StreamplaceDrawer() { // are we in the live dashboard? const [isLiveDashboard, setIsLiveDashboard] = useState(false); useEffect(() => { - if (!isLiveDashboard && userIsLive) { + if (!isLiveDashboard && userIsLive && isWeb) { toast.show( "You are streaming!", "Do you want to go to your Live Dashboard?", diff --git a/js/components/src/components/dashboard/header.tsx b/js/components/src/components/dashboard/header.tsx index f10710eb..9b6d94f1 100644 --- a/js/components/src/components/dashboard/header.tsx +++ b/js/components/src/components/dashboard/header.tsx @@ -8,7 +8,7 @@ interface MetricItemProps { icon: any; label: string; value: string; - status?: "good" | "warning" | "error"; + status?: "good" | "warning" | "error" | "pre-live"; } function MetricItem({ icon: Icon, label, value, status }: MetricItemProps) { diff --git a/js/components/src/components/mobile-player/ui/input.tsx b/js/components/src/components/mobile-player/ui/input.tsx index ac5888ed..3fa09e1a 100644 --- a/js/components/src/components/mobile-player/ui/input.tsx +++ b/js/components/src/components/mobile-player/ui/input.tsx @@ -7,7 +7,6 @@ const { gap, h, layout, mt, p, position, px, py, sizes, w } = atoms; type InputPanelProps = { title: string | undefined; setTitle: (title: string) => void; - ingestStarting: boolean; toggleGoLive: () => void; isLive: boolean; toggleStopStream?: () => void; @@ -16,7 +15,6 @@ type InputPanelProps = { export function InputPanel({ title, setTitle, - ingestStarting, toggleGoLive, isLive, toggleStopStream, @@ -51,9 +49,7 @@ export function InputPanel({ /> )} - {ingestStarting ? ( - Starting your stream... - ) : isLive ? ( + {isLive ? ( ; let color = "#d44"; - if (connectionQuality === "good") { + if (connectionQuality === "pre-live") { + icon = ; + color = atoms.colors.blue[500]; + } else if (connectionQuality === "good") { icon = ; color = "#4d4"; } else if (connectionQuality === "degraded") { @@ -23,6 +26,18 @@ export function MetricsPanel({ showMetrics }: MetricsPanelProps) { color = "#d44"; } + const connectionText = () => { + if (connectionQuality === "pre-live") { + return "READY TO STREAM"; + } else if (connectionQuality === "good") { + return "GOOD"; + } else if (connectionQuality === "degraded") { + return "DEGRADED"; + } else { + return "POOR"; + } + }; + return ( - {connectionQuality.toUpperCase()} + {connectionText()} {showMetrics && ( diff --git a/js/components/src/components/mobile-player/video-async.native.tsx b/js/components/src/components/mobile-player/video-async.native.tsx index 821c236b..ce3cd04a 100644 --- a/js/components/src/components/mobile-player/video-async.native.tsx +++ b/js/components/src/components/mobile-player/video-async.native.tsx @@ -285,9 +285,9 @@ export function NativeWHEP(props?: { export function NativeIngestPlayer(props?: { objectFit?: "contain" | "cover"; }) { - const ingestStarting = useIngestPlayerStore((x) => x.ingestStarting); const ingestMediaSource = useIngestPlayerStore((x) => x.ingestMediaSource); const ingestAutoStart = useIngestPlayerStore((x) => x.ingestAutoStart); + const setIngestLive = useIngestPlayerStore((x) => x.setIngestLive); const setStatus = useIngestPlayerStore((x) => x.setStatus); const setVideoRef = usePlayerStore((x) => x.setVideoRef); @@ -377,17 +377,19 @@ export function NativeIngestPlayer(props?: { }, [ingestMediaSource, ingestCamera]); useEffect(() => { - if (!ingestStarting && !ingestAutoStart) { - setRemoteMediaStream(null); - return; + if (localMediaStream) { + setIngestLive(true); } + }, [localMediaStream]); + + useEffect(() => { if (!localMediaStream) { return; } console.log("setting remote media stream", localMediaStream); // @ts-expect-error: WebRTCMediaStream may not have all MediaStream properties, but is compatible for our use setRemoteMediaStream(localMediaStream); - }, [localMediaStream, ingestStarting, ingestAutoStart, setRemoteMediaStream]); + }, [localMediaStream, ingestAutoStart, setRemoteMediaStream]); if (!localMediaStream) { return null; diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index ace0d103..fb57b1ed 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -542,7 +542,6 @@ export function WebRTCPlayerInner({ } export function WebcamIngestPlayer(props: VideoProps) { - const ingestStarting = usePlayerStore((x) => x.ingestStarting); const ingestMediaSource = usePlayerStore((x) => x.ingestMediaSource); const ingestAutoStart = usePlayerStore((x) => x.ingestAutoStart); @@ -607,7 +606,7 @@ export function WebcamIngestPlayer(props: VideoProps) { }, [ingestMediaSource]); useEffect(() => { - if (!ingestStarting && !ingestAutoStart) { + if (!ingestAutoStart) { setRemoteMediaStream(null); return; } @@ -615,7 +614,7 @@ export function WebcamIngestPlayer(props: VideoProps) { return; } setRemoteMediaStream(localMediaStream); - }, [localMediaStream, ingestStarting, ingestAutoStart]); + }, [localMediaStream, ingestAutoStart]); useEffect(() => { if (!videoElement) { diff --git a/js/components/src/hooks/useLivestreamInfo.ts b/js/components/src/hooks/useLivestreamInfo.ts index 690a1142..3bef6835 100644 --- a/js/components/src/hooks/useLivestreamInfo.ts +++ b/js/components/src/hooks/useLivestreamInfo.ts @@ -6,8 +6,6 @@ import { useCreateStreamRecord } from "../streamplace-store"; export function useLivestreamInfo(url?: string) { const ingest = usePlayerStore((x) => x.ingestConnectionState); const profile = useLivestreamStore((x) => x.profile); - const ingestStarting = usePlayerStore((x) => x.ingestStarting); - const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); const setIngestLive = usePlayerStore((x) => x.setIngestLive); const stopIngest = usePlayerStore((x) => x.stopIngest); @@ -39,20 +37,13 @@ export function useLivestreamInfo(url?: string) { keyboardHeight?: number, closeKeyboard?: () => void, ) => { - if (!ingestStarting) { - // Optionally close keyboard if provided - if (closeKeyboard) closeKeyboard(); - setShowCountdown(true); - setIngestStarting(true); - setIngestLive(true); - // wait ~3 seconds before announcing - setTimeout(() => { - handleSubmit(); - }, 3000); - } else { - setIngestStarting(false); - setIngestLive(false); - } + // Optionally close keyboard if provided + if (closeKeyboard) closeKeyboard(); + setShowCountdown(true); + // wait ~3 seconds before announcing + setTimeout(() => { + handleSubmit(); + }, 3000); }; // Stop the current broadcast @@ -70,8 +61,6 @@ export function useLivestreamInfo(url?: string) { setShowCountdown, recordSubmitted, setRecordSubmitted, - ingestStarting, - setIngestStarting, handleSubmit, toggleGoLive, toggleStopStream, diff --git a/js/components/src/hooks/useSegmentTiming.tsx b/js/components/src/hooks/useSegmentTiming.tsx index d01f346a..94571052 100644 --- a/js/components/src/hooks/useSegmentTiming.tsx +++ b/js/components/src/hooks/useSegmentTiming.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { useLivestreamStore } from "../livestream-store"; +import { useLivestream, useLivestreamStore } from "../livestream-store"; -export type ConnectionQuality = "good" | "degraded" | "poor"; +export type ConnectionQuality = "good" | "degraded" | "poor" | "pre-live"; function getLiveConnectionQuality( timeBetweenSegments: number | null, @@ -24,6 +24,7 @@ export function useSegmentTiming() { const [segmentDeltas, setSegmentDeltas] = useState([]); const prevSegmentRef = useRef(); const prevTimestampRef = useRef(null); + const ls = useLivestream(); // Dummy state to force update every second const [, setNow] = useState(Date.now()); @@ -84,5 +85,9 @@ export function useSegmentTiming() { segmentDeltas.length, ); + if (!ls) { + to_ret.connectionQuality = "pre-live"; + } + return to_ret; } diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx index ac885209..fa48bc21 100644 --- a/js/components/src/player-store/player-state.tsx +++ b/js/components/src/player-store/player-state.tsx @@ -38,12 +38,6 @@ export interface PlayerState { /** Function to set the source URL */ setSrc: (src: string) => void; - /** Flag indicating if ingest (stream input) is currently starting */ - ingestStarting: boolean; - - /** Function to set the ingestStarting flag */ - setIngestStarting: (ingestStarting: boolean) => void; - /** Flag indicating if ingest is live */ ingestLive: boolean; setIngestLive: (ingestLive: boolean) => void; diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx index 115dfc94..4eb3ea0b 100644 --- a/js/components/src/player-store/player-store.tsx +++ b/js/components/src/player-store/player-store.tsx @@ -28,10 +28,6 @@ export const makePlayerStore = (id?: string): StoreApi => { src: "", setSrc: (src: string) => set(() => ({ src })), - ingestStarting: false, - setIngestStarting: (ingestStarting: boolean) => - set(() => ({ ingestStarting })), - ingestMediaSource: undefined, setIngestMediaSource: (ingestMediaSource: IngestMediaSource | undefined) => set(() => ({ ingestMediaSource })), @@ -45,7 +41,7 @@ export const makePlayerStore = (id?: string): StoreApi => { ingestConnectionState: RTCPeerConnectionState | null, ) => set(() => ({ ingestConnectionState })), - ingestAutoStart: false, + ingestAutoStart: true, setIngestAutoStart: (ingestAutoStart: boolean) => set(() => ({ ingestAutoStart })), diff --git a/js/components/src/player-store/single-player-provider.tsx b/js/components/src/player-store/single-player-provider.tsx index 5effed54..950a39e1 100644 --- a/js/components/src/player-store/single-player-provider.tsx +++ b/js/components/src/player-store/single-player-provider.tsx @@ -143,16 +143,12 @@ export function useCurrentPlayerRendition(): [ * Hook to get the ingest state of the current player */ export function useCurrentPlayerIngest(): { - starting: boolean; - setStarting: (starting: boolean) => void; connectionState: RTCPeerConnectionState | null; setConnectionState: (state: RTCPeerConnectionState | null) => void; startedTimestamp: number | null; setStartedTimestamp: (timestamp: number | null) => void; } { return useCurrentPlayerStore((state) => ({ - starting: state.ingestStarting, - setStarting: state.setIngestStarting, connectionState: state.ingestConnectionState, setConnectionState: state.setIngestConnectionState, startedTimestamp: state.ingestStarted, diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index c1776e2a..a5c7d061 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -156,50 +156,50 @@ export function useCreateStreamRecord() { const u = new URL(url); - let thumbnail: BlobRef | undefined = undefined; - - if (customThumbnail) { - try { - thumbnail = await uploadThumbnail(agent, customThumbnail); - } catch (e) { - throw new Error(`Custom thumbnail upload failed ${e}`); - } - } else { - // No custom thumbnail: fetch the server-side image and upload it - // try thrice lel - let tries = 0; - try { - for (; tries < 3; tries++) { - try { - console.log( - `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, - ); - const thumbnailRes = await fetch( - `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, - ); - if (!thumbnailRes.ok) { - throw new Error( - `Failed to fetch thumbnail: ${thumbnailRes.status})`, - ); - } - const thumbnailBlob = await thumbnailRes.blob(); - console.log(thumbnailBlob); - thumbnail = await uploadThumbnail(agent, thumbnailBlob); - } catch (e) { - console.warn( - `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, - ); - // Wait 1 second before retrying - await new Promise((resolve) => setTimeout(resolve, 2000)); - if (tries === 2) { - throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); - } - } - } - } catch (e) { - throw new Error(`Thumbnail upload failed ${e}`); - } - } + // let thumbnail: BlobRef | undefined = undefined; + + // if (customThumbnail) { + // try { + // thumbnail = await uploadThumbnail(agent, customThumbnail); + // } catch (e) { + // throw new Error(`Custom thumbnail upload failed ${e}`); + // } + // } else { + // // No custom thumbnail: fetch the server-side image and upload it + // // try thrice lel + // let tries = 0; + // try { + // for (; tries < 3; tries++) { + // try { + // console.log( + // `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, + // ); + // const thumbnailRes = await fetch( + // `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, + // ); + // if (!thumbnailRes.ok) { + // throw new Error( + // `Failed to fetch thumbnail: ${thumbnailRes.status})`, + // ); + // } + // const thumbnailBlob = await thumbnailRes.blob(); + // console.log(thumbnailBlob); + // thumbnail = await uploadThumbnail(agent, thumbnailBlob); + // } catch (e) { + // console.warn( + // `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, + // ); + // // Wait 1 second before retrying + // await new Promise((resolve) => setTimeout(resolve, 2000)); + // if (tries === 2) { + // throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); + // } + // } + // } + // } catch (e) { + // throw new Error(`Thumbnail upload failed ${e}`); + // } + // } let newPost: undefined | { uri: string; cid: string } = undefined; @@ -221,7 +221,7 @@ export function useCreateStreamRecord() { u, profile.data, params, - thumbnail, + undefined, agent, ); @@ -264,7 +264,7 @@ export function useCreateStreamRecord() { // e.g. `@streamplace/components/0.1.0 (ios, 32.0)` agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, post: newPost, - thumb: thumbnail, + // thumb: thumbnail, idleTimeoutSeconds: idleTimeoutSeconds, }; console.log("record", record); diff --git a/pkg/api/playback.go b/pkg/api/playback.go index cdae6e22..87b49c8e 100644 --- a/pkg/api/playback.go +++ b/pkg/api/playback.go @@ -16,6 +16,7 @@ import ( "stream.place/streamplace/pkg/errors" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/spmetrics" + "stream.place/streamplace/pkg/streamplace" ) func (a *StreamplaceAPI) NormalizeUser(ctx context.Context, user string) (string, error) { @@ -272,6 +273,29 @@ func (a *StreamplaceAPI) HandleThumbnailPlayback(ctx context.Context) httprouter errors.WriteHTTPNotFound(w, "user not found", err) return } + ls, err := a.Model.GetLatestLivestreamForRepo(user) + if err != nil { + errors.WriteHTTPInternalServerError(w, "could not get livestream", err) + return + } + if ls == nil { + errors.WriteHTTPNotFound(w, "livestream not found", err) + return + } + lsrv, err := ls.ToLivestreamView() + if err != nil { + errors.WriteHTTPInternalServerError(w, "could not marshal livestream", err) + return + } + lsr, ok := lsrv.Record.Val.(*streamplace.Livestream) + if !ok { + errors.WriteHTTPInternalServerError(w, "livestream is not a streamplace livestream", nil) + return + } + if lsr.EndedAt != nil { + errors.WriteHTTPNotFound(w, "livestream has ended", nil) + return + } thumb, err := a.LocalDB.LatestThumbnailForUser(user) if err != nil { errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index e1746063..8ff036b0 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -195,17 +195,17 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment return ss.statefulDB.UpsertBroadcastOrigin(spseg.Creator, ss.cli.BroadcasterDID(), time.Now()) }) - // everything else is for published segments - if !notif.Metadata.Published { - return nil - } - if ss.cli.Thumbnail { ss.Go(ctx, func() error { return ss.Thumbnail(ctx, spseg.Creator, notif) }) } + // everything else is for published segments + if !notif.Metadata.Published { + return nil + } + if notif.Local { ss.UpdateStatus(ctx, spseg.Creator) ss.UpdateBroadcastOrigin(ctx) -- 2.51.2 From 0952ccf9e159bebacfe1ddd817681b7fc14ada6e Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 25 Feb 2026 17:03:24 -0800 Subject: [PATCH 46/49] prelive: implement place.stream.live.startStopLivestream --- Makefile | 1 + .../live-dashboard/livestream-panel.tsx | 2 +- .../src/components/mobile-player/video.tsx | 13 +- js/components/src/hooks/useLivestreamInfo.ts | 7 +- .../src/streamplace-store/stream.tsx | 147 ++------ .../live/place-stream-live-startlivestream.md | 101 ++++++ .../live/place-stream-live-stoplivestream.md | 82 +++++ .../content/docs/lex-reference/openapi.json | 103 ++++++ .../place/stream/live/startLivestream.json | 51 +++ .../place/stream/live/stopLivestream.json | 37 ++ pkg/spxrpc/place_stream_live.go | 322 ++++++++++++++++-- pkg/spxrpc/stubs.go | 38 +++ pkg/streamplace/livestartLivestream.go | 38 +++ pkg/streamplace/livestopLivestream.go | 33 ++ 14 files changed, 808 insertions(+), 167 deletions(-) create mode 100644 js/docs/src/content/docs/lex-reference/live/place-stream-live-startlivestream.md create mode 100644 js/docs/src/content/docs/lex-reference/live/place-stream-live-stoplivestream.md create mode 100644 lexicons/place/stream/live/startLivestream.json create mode 100644 lexicons/place/stream/live/stopLivestream.json create mode 100644 pkg/streamplace/livestartLivestream.go create mode 100644 pkg/streamplace/livestopLivestream.go diff --git a/Makefile b/Makefile index 0c2d62e4..61e1a0ba 100644 --- a/Makefile +++ b/Makefile @@ -384,6 +384,7 @@ js-lexicons: && sed -i.bak 's/AppBskyGraphBlock\.Main/AppBskyGraphBlock\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ && sed -i.bak 's/PlaceStreamMultistreamTarget\.Main/PlaceStreamMultistreamTarget\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ && sed -i.bak 's/PlaceStreamChatProfile\.Main/PlaceStreamChatProfile\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ + && sed -i.bak 's/PlaceStreamLivestream\.Main/PlaceStreamLivestream\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream/live -type f) \ && for x in $$(find ./js/streamplace/src/lexicons -type f -name '*.ts'); do \ echo 'import { ComAtprotoSyncGetRepo, AppBskyRichtextFacet, AppBskyGraphBlock, ComAtprotoRepoStrongRef, AppBskyActorDefs, ComAtprotoSyncListRepos, AppBskyActorGetProfile, AppBskyFeedGetFeedSkeleton, ComAtprotoIdentityResolveHandle, ComAtprotoModerationCreateReport, ComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoDescribeRepo, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord, ComAtprotoRepoUploadBlob, ComAtprotoServerDescribeServer, ComAtprotoSyncGetRecord, ComAtprotoSyncListReposComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoIdentityRefreshIdentity } from "@atproto/api"' >> $$x; \ done \ diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index f733fc24..cf70349a 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -353,7 +353,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { if (!livestream) return; setEndingLivestream(true); try { - await endLivestream(livestream); + await endLivestream(); } catch (error) { console.error("Error ending livestream:", error); toast.show("Error", "Failed to end livestream", { diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index fb57b1ed..085b1ddf 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -544,6 +544,7 @@ export function WebRTCPlayerInner({ export function WebcamIngestPlayer(props: VideoProps) { const ingestMediaSource = usePlayerStore((x) => x.ingestMediaSource); const ingestAutoStart = usePlayerStore((x) => x.ingestAutoStart); + const setIngestLive = usePlayerStore((x) => x.setIngestLive); const [error, setError] = useState(null); @@ -606,15 +607,17 @@ export function WebcamIngestPlayer(props: VideoProps) { }, [ingestMediaSource]); useEffect(() => { - if (!ingestAutoStart) { - setRemoteMediaStream(null); - return; - } + // if (!ingestAutoStart) { + // setRemoteMediaStream(null); + // return; + // } if (!localMediaStream) { return; } + console.log("setting remote media stream", localMediaStream); + setIngestLive(true); setRemoteMediaStream(localMediaStream); - }, [localMediaStream, ingestAutoStart]); + }, [localMediaStream, setIngestLive, setRemoteMediaStream]); useEffect(() => { if (!videoElement) { diff --git a/js/components/src/hooks/useLivestreamInfo.ts b/js/components/src/hooks/useLivestreamInfo.ts index 3bef6835..77dd9688 100644 --- a/js/components/src/hooks/useLivestreamInfo.ts +++ b/js/components/src/hooks/useLivestreamInfo.ts @@ -1,13 +1,12 @@ import { useState } from "react"; import { useLivestreamStore } from "../livestream-store"; import { usePlayerStore } from "../player-store"; -import { useCreateStreamRecord } from "../streamplace-store"; +import { useCreateStreamRecord, useEndLivestream } from "../streamplace-store"; export function useLivestreamInfo(url?: string) { const ingest = usePlayerStore((x) => x.ingestConnectionState); const profile = useLivestreamStore((x) => x.profile); - const setIngestLive = usePlayerStore((x) => x.setIngestLive); - const stopIngest = usePlayerStore((x) => x.stopIngest); + const endLivestream = useEndLivestream(); const createStreamRecord = useCreateStreamRecord(); @@ -49,7 +48,7 @@ export function useLivestreamInfo(url?: string) { // Stop the current broadcast const toggleStopStream = () => { console.log("Stopping stream..."); - stopIngest(); + endLivestream(); }; return { diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index a5c7d061..dceb13f1 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -127,7 +127,6 @@ export function useCreateStreamRecord() { let agent = usePDSAgent(); let url = useUrl(); const uploadThumbnail = useUploadThumbnail(); - return async ({ title, customThumbnail, @@ -143,97 +142,10 @@ export function useCreateStreamRecord() { notificationSettings?: PlaceStreamLivestream.NotificationSettings; idleTimeoutSeconds?: number; }) => { - if (typeof submitPost !== "boolean") { - submitPost = true; - } if (!agent) { throw new Error("No PDS agent found"); } - if (!agent.did) { - throw new Error("No user DID found, assuming not logged in"); - } - - const u = new URL(url); - - // let thumbnail: BlobRef | undefined = undefined; - - // if (customThumbnail) { - // try { - // thumbnail = await uploadThumbnail(agent, customThumbnail); - // } catch (e) { - // throw new Error(`Custom thumbnail upload failed ${e}`); - // } - // } else { - // // No custom thumbnail: fetch the server-side image and upload it - // // try thrice lel - // let tries = 0; - // try { - // for (; tries < 3; tries++) { - // try { - // console.log( - // `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, - // ); - // const thumbnailRes = await fetch( - // `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, - // ); - // if (!thumbnailRes.ok) { - // throw new Error( - // `Failed to fetch thumbnail: ${thumbnailRes.status})`, - // ); - // } - // const thumbnailBlob = await thumbnailRes.blob(); - // console.log(thumbnailBlob); - // thumbnail = await uploadThumbnail(agent, thumbnailBlob); - // } catch (e) { - // console.warn( - // `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, - // ); - // // Wait 1 second before retrying - // await new Promise((resolve) => setTimeout(resolve, 2000)); - // if (tries === 2) { - // throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); - // } - // } - // } - // } catch (e) { - // throw new Error(`Thumbnail upload failed ${e}`); - // } - // } - - let newPost: undefined | { uri: string; cid: string } = undefined; - - const did = agent.did; - const profile = await agent.getProfile({ actor: did }); - - if (submitPost) { - if (!profile) { - throw new Error("No profile found for the user DID"); - } - - const params = new URLSearchParams({ - did: did, - time: new Date().toISOString(), - }); - - let post = await buildGoLivePost( - title, - u, - profile.data, - params, - undefined, - agent, - ); - - newPost = await createNewPost(agent, post); - - if (!newPost.uri || !newPost.cid) { - throw new Error( - "Cannot read properties of undefined (reading 'uri' or 'cid')", - ); - } - } - let platform: string = Platform.OS; let platVersion: string = Platform.Version ? Platform.Version.toString() @@ -246,12 +158,12 @@ export function useCreateStreamRecord() { ) { platVersion = getBrowserName(window.navigator.userAgent); } - - const thisUrl = `${url}/${profile.data.handle}`; - if (!canonicalUrl) { - canonicalUrl = thisUrl; + if (!agent.did) { + throw new Error("No user DID found, assuming not logged in"); } + const thisUrl = `${url}/${agent.did}`; + const record: PlaceStreamLivestream.Record = { $type: "place.stream.livestream", title: title, @@ -263,22 +175,29 @@ export function useCreateStreamRecord() { // user agent style string // e.g. `@streamplace/components/0.1.0 (ios, 32.0)` agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, - post: newPost, - // thumb: thumbnail, idleTimeoutSeconds: idleTimeoutSeconds, }; - console.log("record", record); if (notificationSettings) { record.notificationSettings = notificationSettings; } - await agent.com.atproto.repo.createRecord({ - repo: agent.did, - collection: "place.stream.livestream", - record, + if (customThumbnail) { + try { + const thumbnail = await uploadThumbnail(agent, customThumbnail); + record.thumb = thumbnail; + } catch (e) { + throw new Error(`Custom thumbnail upload failed ${e}`); + } + } + + const output = await agent.place.stream.live.startLivestream({ + livestream: record, + streamer: agent.did, + createBlueskyPost: submitPost, }); - return record; + + return output; }; } @@ -347,7 +266,7 @@ export function useUpdateStreamRecord(customUrl: string | null = null) { export function useEndLivestream() { let agent = usePDSAgent(); - return async (livestream: LivestreamViewHydrated | null) => { + return async () => { if (!agent) { throw new Error("No PDS agent found"); } @@ -356,30 +275,6 @@ export function useEndLivestream() { throw new Error("No user DID found, assuming not logged in"); } - if (!livestream) { - throw new Error("No latest record"); - } - - let rkey = livestream.uri.split("/").pop(); - if (!rkey) { - throw new Error("No rkey?"); - } - - if (livestream.record.endedAt) { - throw new Error("Livestream already ended"); - } - - let record: PlaceStreamLivestream.Record = { - ...livestream.record, - endedAt: new Date().toISOString(), - }; - - await agent.com.atproto.repo.putRecord({ - repo: agent.did, - collection: "place.stream.livestream", - rkey, - record, - }); - return record; + return await agent.place.stream.live.stopLivestream({}); }; } diff --git a/js/docs/src/content/docs/lex-reference/live/place-stream-live-startlivestream.md b/js/docs/src/content/docs/lex-reference/live/place-stream-live-startlivestream.md new file mode 100644 index 00000000..e8a6df56 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/live/place-stream-live-startlivestream.md @@ -0,0 +1,101 @@ +--- +title: place.stream.live.startLivestream +description: Reference for the place.stream.live.startLivestream lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `procedure` + +Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such. + +**Parameters:** _(None defined)_ + +**Input:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ------------------- | ------------------------------------------------------------------- | ----- | ----------------------------------------------------------- | ------------- | +| `livestream` | [`place.stream.livestream`](/lex-reference/place-stream-livestream) | ✅ | | | +| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | +| `createBlueskyPost` | `boolean` | ❌ | Whether to create a Bluesky post announcing the livestream. | | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ----- | -------- | ----- | --------------------------------- | ------------- | +| `uri` | `string` | ✅ | The URI of the livestream record. | Format: `uri` | +| `cid` | `string` | ✅ | The CID of the livestream record. | Format: `cid` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.live.startLivestream", + "defs": { + "main": { + "type": "procedure", + "description": "Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["streamer", "livestream"], + "properties": { + "livestream": { + "type": "ref", + "ref": "place.stream.livestream" + }, + "streamer": { + "type": "string", + "format": "did", + "description": "The DID of the streamer." + }, + "createBlueskyPost": { + "type": "boolean", + "description": "Whether to create a Bluesky post announcing the livestream." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "The URI of the livestream record." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "The CID of the livestream record." + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/live/place-stream-live-stoplivestream.md b/js/docs/src/content/docs/lex-reference/live/place-stream-live-stoplivestream.md new file mode 100644 index 00000000..071a992e --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/live/place-stream-live-stoplivestream.md @@ -0,0 +1,82 @@ +--- +title: place.stream.live.stopLivestream +description: Reference for the place.stream.live.stopLivestream lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `procedure` + +Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video. + +**Parameters:** _(None defined)_ + +**Input:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +_(No properties defined)_ +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ----- | -------- | ----- | --------------------------------------------- | ------------- | +| `uri` | `string` | ✅ | The URI of the stopped livestream record. | Format: `uri` | +| `cid` | `string` | ✅ | The new CID of the stopped livestream record. | Format: `cid` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.live.stopLivestream", + "defs": { + "main": { + "type": "procedure", + "description": "Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [], + "properties": {} + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "The URI of the stopped livestream record." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "The new CID of the stopped livestream record." + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 44570b9f..41d6ddcb 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -1553,6 +1553,106 @@ ] } }, + "/xrpc/place.stream.live.startLivestream": { + "post": { + "summary": "Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such.", + "operationId": "place.stream.live.startLivestream", + "tags": ["place.stream.live"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "The URI of the livestream record.", + "format": "uri" + }, + "cid": { + "type": "string", + "description": "The CID of the livestream record.", + "format": "cid" + } + }, + "required": ["uri", "cid"] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "livestream": { + "$ref": "#/components/schemas/place.stream.livestream" + }, + "streamer": { + "type": "string", + "description": "The DID of the streamer.", + "format": "did" + }, + "createBlueskyPost": { + "type": "boolean", + "description": "Whether to create a Bluesky post announcing the livestream." + } + }, + "required": ["streamer", "livestream"] + } + } + } + } + } + }, + "/xrpc/place.stream.live.stopLivestream": { + "post": { + "summary": "Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video.", + "operationId": "place.stream.live.stopLivestream", + "tags": ["place.stream.live"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "The URI of the stopped livestream record.", + "format": "uri" + }, + "cid": { + "type": "string", + "description": "The new CID of the stopped livestream record.", + "format": "cid" + } + }, + "required": ["uri", "cid"] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + } + } + }, "/xrpc/place.stream.live.subscribeSegments": { "get": { "summary": "Subscribe to a stream's new segments as they come in!", @@ -3451,6 +3551,9 @@ }, "required": ["did", "handle"] }, + "place.stream.livestream": { + "description": "Unknown type" + }, "place.stream.live.subscribeSegments_segment": { "type": "string", "format": "byte", diff --git a/lexicons/place/stream/live/startLivestream.json b/lexicons/place/stream/live/startLivestream.json new file mode 100644 index 00000000..4509a0e9 --- /dev/null +++ b/lexicons/place/stream/live/startLivestream.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "place.stream.live.startLivestream", + "defs": { + "main": { + "type": "procedure", + "description": "Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["streamer", "livestream"], + "properties": { + "livestream": { + "type": "ref", + "ref": "place.stream.livestream" + }, + "streamer": { + "type": "string", + "format": "did", + "description": "The DID of the streamer." + }, + "createBlueskyPost": { + "type": "boolean", + "description": "Whether to create a Bluesky post announcing the livestream." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "The URI of the livestream record." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "The CID of the livestream record." + } + } + } + } + } + } +} diff --git a/lexicons/place/stream/live/stopLivestream.json b/lexicons/place/stream/live/stopLivestream.json new file mode 100644 index 00000000..78ba2e16 --- /dev/null +++ b/lexicons/place/stream/live/stopLivestream.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "place.stream.live.stopLivestream", + "defs": { + "main": { + "type": "procedure", + "description": "Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [], + "properties": {} + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "The URI of the stopped livestream record." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "The new CID of the stopped livestream record." + } + } + } + } + } + } +} diff --git a/pkg/spxrpc/place_stream_live.go b/pkg/spxrpc/place_stream_live.go index 9e296bbd..6c19ac20 100644 --- a/pkg/spxrpc/place_stream_live.go +++ b/pkg/spxrpc/place_stream_live.go @@ -1,26 +1,34 @@ package spxrpc import ( + "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" + "os" "strconv" "time" - "github.com/bluesky-social/indigo/lex/util" + comatproto "github.com/bluesky-social/indigo/api/atproto" + bsky "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/bluesky-social/indigo/util" + "github.com/bluesky-social/indigo/xrpc" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" + "stream.place/streamplace/pkg/aqtime" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/spid" "stream.place/streamplace/pkg/spmetrics" - placestreamtypes "stream.place/streamplace/pkg/streamplace" + placestream "stream.place/streamplace/pkg/streamplace" ) -func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *placestreamtypes.LiveDenyTeleport_Input) (*placestreamtypes.LiveDenyTeleport_Output, error) { +func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *placestream.LiveDenyTeleport_Input) (*placestream.LiveDenyTeleport_Output, error) { session, _ := oatproxy.GetOAuthSession(ctx) if session == nil { return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") @@ -50,7 +58,7 @@ func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *p return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to deny teleport") } - cancelMsg := &placestreamtypes.Livestream_TeleportCanceled{ + cancelMsg := &placestream.Livestream_TeleportCanceled{ LexiconTypeID: "place.stream.livestream#teleportCanceled", TeleportUri: input.Uri, Reason: "denied", @@ -59,7 +67,7 @@ func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *p s.bus.Publish(teleport.RepoDID, cancelMsg) s.bus.Publish(teleport.TargetDID, cancelMsg) - return &placestreamtypes.LiveDenyTeleport_Output{ + return &placestream.LiveDenyTeleport_Output{ Success: true, }, nil } @@ -72,7 +80,7 @@ var replicationUpgrader = websocket.Upgrader{ }, } -func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before string, limit int, userDID string) (*placestreamtypes.LiveGetSegments_Output, error) { +func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before string, limit int, userDID string) (*placestream.LiveGetSegments_Output, error) { if userDID == "" { return nil, echo.NewHTTPError(http.StatusBadRequest, "User DID is required") } @@ -102,7 +110,7 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st if err != nil { return nil, fmt.Errorf("error proxying to peer: %w", err) } - var output placestreamtypes.LiveGetSegments_Output + var output placestream.LiveGetSegments_Output err = json.Unmarshal(data, &output) if err != nil { return nil, fmt.Errorf("error unmarshalling response: %w", err) @@ -123,8 +131,8 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st } // Convert segments to the expected output format - output := &placestreamtypes.LiveGetSegments_Output{ - Segments: make([]*placestreamtypes.Segment_SegmentView, len(segments)), + output := &placestream.LiveGetSegments_Output{ + Segments: make([]*placestream.Segment_SegmentView, len(segments)), } for i, segment := range segments { @@ -136,9 +144,9 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get CID: %s", err)) } - ltd := &util.LexiconTypeDecoder{Val: record} + ltd := &lexutil.LexiconTypeDecoder{Val: record} - output.Segments[i] = &placestreamtypes.Segment_SegmentView{ + output.Segments[i] = &placestream.Segment_SegmentView{ Record: ltd, Cid: c.String(), } @@ -147,11 +155,11 @@ func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before st return output, nil } -func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before string, limit int) (*placestreamtypes.LiveGetLiveUsers_Output, error) { +func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before string, limit int) (*placestream.LiveGetLiveUsers_Output, error) { // Check cache first cacheKey := fmt.Sprintf("live_users_%s_%d", before, limit) if cached, found := s.LiveUsersCache.Get(cacheKey); found { - return cached.(*placestreamtypes.LiveGetLiveUsers_Output), nil + return cached.(*placestream.LiveGetLiveUsers_Output), nil } var beforeTime *time.Time @@ -175,7 +183,7 @@ func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before s return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch livestreams") } - streams := make([]*placestreamtypes.Livestream_LivestreamView, len(ls)) + streams := make([]*placestream.Livestream_LivestreamView, len(ls)) for i, l := range ls { stream, err := l.ToLivestreamView() @@ -183,14 +191,14 @@ func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before s return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to convert livestream to streamplace livestream: %s", err)) } viewers := spmetrics.GetViewCount(stream.Author.Did) - stream.ViewerCount = &placestreamtypes.Livestream_ViewerCount{ + stream.ViewerCount = &placestream.Livestream_ViewerCount{ LexiconTypeID: "place.stream.livestream#viewerCount", Count: int64(viewers), } streams[i] = stream } - liveUsers := &placestreamtypes.LiveGetLiveUsers_Output{ + liveUsers := &placestream.LiveGetLiveUsers_Output{ Streams: streams, } @@ -254,7 +262,7 @@ func (s *Server) handlePlaceStreamLiveSubscribeSegments(c echo.Context) error { } } -func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, userDID string) (*placestreamtypes.LiveGetRecommendations_Output, error) { +func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, userDID string) (*placestream.LiveGetRecommendations_Output, error) { if userDID == "" { return nil, echo.NewHTTPError(http.StatusBadRequest, "userDID is required") } @@ -275,16 +283,16 @@ func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, us } if len(liveStreamers) > 0 { - var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem + var recommendations []*placestream.LiveGetRecommendations_Output_Recommendations_Elem for _, did := range liveStreamers { - recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ - LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ + recommendations = append(recommendations, &placestream.LiveGetRecommendations_Output_Recommendations_Elem{ + LiveGetRecommendations_LivestreamRecommendation: &placestream.LiveGetRecommendations_LivestreamRecommendation{ Did: did, Source: "streamer", }, }) } - return &placestreamtypes.LiveGetRecommendations_Output{ + return &placestream.LiveGetRecommendations_Output{ Recommendations: recommendations, UserDID: &userDID, }, nil @@ -308,16 +316,16 @@ func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, us } if len(liveFollows) > 0 { - var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem + var recommendations []*placestream.LiveGetRecommendations_Output_Recommendations_Elem for _, did := range liveFollows { - recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ - LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ + recommendations = append(recommendations, &placestream.LiveGetRecommendations_Output_Recommendations_Elem{ + LiveGetRecommendations_LivestreamRecommendation: &placestream.LiveGetRecommendations_LivestreamRecommendation{ Did: did, Source: "follows", }, }) } - return &placestreamtypes.LiveGetRecommendations_Output{ + return &placestream.LiveGetRecommendations_Output{ Recommendations: recommendations, UserDID: &userDID, }, nil @@ -331,24 +339,276 @@ func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, us if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") } - var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem + var recommendations []*placestream.LiveGetRecommendations_Output_Recommendations_Elem for _, did := range liveDefaults { - recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ - LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ + recommendations = append(recommendations, &placestream.LiveGetRecommendations_Output_Recommendations_Elem{ + LiveGetRecommendations_LivestreamRecommendation: &placestream.LiveGetRecommendations_LivestreamRecommendation{ Did: did, Source: "host", }, }) } - return &placestreamtypes.LiveGetRecommendations_Output{ + return &placestream.LiveGetRecommendations_Output{ Recommendations: recommendations, UserDID: &userDID, }, nil } // No recommendations available - return &placestreamtypes.LiveGetRecommendations_Output{ - Recommendations: []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{}, + return &placestream.LiveGetRecommendations_Output{ + Recommendations: []*placestream.LiveGetRecommendations_Output_Recommendations_Elem{}, UserDID: &userDID, }, nil } + +func (s *Server) handlePlaceStreamLiveStartLivestream(ctx context.Context, body *placestream.LiveStartLivestream_Input) (*placestream.LiveStartLivestream_Output, error) { + session, _ := oatproxy.GetOAuthSession(ctx) + if session != nil { + if session.DID != body.Streamer { + return nil, echo.NewHTTPError(http.StatusForbidden, "you are not the streamer") + } + } else { + svc := GetServiceAuth(ctx) + if svc != nil { + streamerSession, err := s.statefulDB.GetSessionByDID(body.Streamer) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting streamer session", err) + } + if streamerSession == nil { + return nil, echo.NewHTTPError(http.StatusNotFound, "streamer session not found") + } + session = streamerSession + } else { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "you are not authorized") + } + } + + // proxy to the origin node if the streamer is broadcasting elsewhere + origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(session.DID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) + } + myDID := s.cli.ServerDID() + if origin != nil && origin.ServerDID != myDID { + bs, err := json.Marshal(body) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error marshalling body", err) + } + data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "POST", "place.stream.live.startLivestream", + url.Values{}, + bytes.NewReader(bs), "application/json") + if err != nil { + return nil, err + } + var output placestream.LiveStartLivestream_Output + err = json.Unmarshal(data, &output) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error unmarshalling response", err) + } + return &output, nil + } + + _, client := oatproxy.GetOAuthSession(ctx) + if client == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required to start livestream") + } + + livestream := body.Livestream + now := time.Now().UTC().Format(time.RFC3339) + livestream.LexiconTypeID = "place.stream.livestream" + livestream.CreatedAt = now + livestream.LastSeenAt = &now + + if livestream.Thumb == nil { + // Step 1: get latest thumbnail from localDB and upload to user's PDS + var thumb *lexutil.LexBlob + dbThumb, err := s.localDB.LatestThumbnailForUser(session.DID) + if err != nil { + log.Error(ctx, "failed to get latest thumbnail", "err", err) + } + if dbThumb != nil { + aqt := aqtime.FromTime(dbThumb.Segment.StartTime) + fpath, err := s.cli.SegmentFilePath(session.DID, fmt.Sprintf("%s.%s", aqt.String(), dbThumb.Format)) + if err != nil { + log.Error(ctx, "failed to get thumbnail file path", "err", err) + } else { + thumbData, err := os.ReadFile(fpath) + if err != nil { + log.Error(ctx, "failed to read thumbnail file", "err", err) + } else { + mimeType := "image/jpeg" + if dbThumb.Format == "png" { + mimeType = "image/png" + } + + // Step 2: upload to user's PDS + var uploadOut comatproto.RepoUploadBlob_Output + err = client.Do(ctx, xrpc.Procedure, mimeType, "com.atproto.repo.uploadBlob", nil, bytes.NewReader(thumbData), &uploadOut) + if err != nil { + log.Error(ctx, "failed to upload thumbnail to PDS", "err", err) + } else { + thumb = uploadOut.Blob + } + } + } + } + livestream.Thumb = thumb + } + + // Step 3: create a Bluesky post announcing the livestream + repo, err := s.model.GetRepo(session.DID) + if err != nil { + log.Error(ctx, "failed to get repo", "err", err) + } + + handle := session.DID + if repo != nil && repo.Handle != "" { + handle = repo.Handle + } + + canonicalUrl := fmt.Sprintf("https://%s/%s", s.cli.BroadcasterHost, handle) + if livestream.CanonicalUrl != nil && *livestream.CanonicalUrl != "" { + canonicalUrl = *livestream.CanonicalUrl + } + + if body.CreateBlueskyPost == nil || *body.CreateBlueskyPost { + prefix := "🔴 LIVE " + suffix := " " + livestream.Title + postText := prefix + canonicalUrl + suffix + + linkStart := int64(len(prefix)) + linkEnd := linkStart + int64(len(canonicalUrl)) + + postRecord := &bsky.FeedPost{ + LexiconTypeID: "app.bsky.feed.post", + Text: postText, + CreatedAt: now, + Langs: []string{"en"}, + Facets: []*bsky.RichtextFacet{ + { + Index: &bsky.RichtextFacet_ByteSlice{ + ByteStart: linkStart, + ByteEnd: linkEnd, + }, + Features: []*bsky.RichtextFacet_Features_Elem{ + { + RichtextFacet_Link: &bsky.RichtextFacet_Link{ + LexiconTypeID: "app.bsky.richtext.facet#link", + Uri: canonicalUrl, + }, + }, + }, + }, + }, + Embed: &bsky.FeedPost_Embed{ + EmbedExternal: &bsky.EmbedExternal{ + External: &bsky.EmbedExternal_External{ + Title: fmt.Sprintf("@%s is 🔴LIVE on %s!", handle, s.cli.BroadcasterHost), + Uri: canonicalUrl, + Description: livestream.Title, + Thumb: livestream.Thumb, + }, + }, + }, + } + + postInput := comatproto.RepoCreateRecord_Input{ + Collection: "app.bsky.feed.post", + Record: &lexutil.LexiconTypeDecoder{Val: postRecord}, + Repo: session.DID, + } + var postOutput comatproto.RepoCreateRecord_Output + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, postInput, &postOutput) + if err != nil { + log.Error(ctx, "failed to create bluesky post", "err", err) + } else { + livestream.Post = &comatproto.RepoStrongRef{ + Uri: postOutput.Uri, + Cid: postOutput.Cid, + } + } + } + + // Step 4: create the place.stream.livestream record + lsInput := comatproto.RepoCreateRecord_Input{ + Collection: "place.stream.livestream", + Record: &lexutil.LexiconTypeDecoder{Val: livestream}, + Repo: session.DID, + } + var lsOutput comatproto.RepoCreateRecord_Output + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, lsInput, &lsOutput) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create livestream record: %v", err)) + } + + return &placestream.LiveStartLivestream_Output{ + Uri: lsOutput.Uri, + Cid: lsOutput.Cid, + }, nil +} + +func (s *Server) handlePlaceStreamLiveStopLivestream(ctx context.Context, body *placestream.LiveStopLivestream_Input) (*placestream.LiveStopLivestream_Output, error) { + now := time.Now().UTC().Format(util.ISO8601) + session, _ := oatproxy.GetOAuthSession(ctx) + + _, client := oatproxy.GetOAuthSession(ctx) + if client == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required to stop livestream") + } + + livestream, err := s.model.GetLatestLivestreamForRepo(session.DID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting livestream", err) + } + + livestreamView, err := livestream.ToLivestreamView() + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error converting livestream to view", err) + } + + livestreamRecord, ok := livestreamView.Record.Val.(*placestream.Livestream) + if !ok { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "livestream is not a streamplace livestream") + } + + if livestreamRecord.EndedAt != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, "livestream has already ended") + } + + livestreamRecord.EndedAt = &now + + aturi, err := syntax.ParseATURI(livestreamView.Uri) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error parsing ATURI", err) + } + + var swapRecord *string + getOutput := comatproto.RepoGetRecord_Output{} + err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ + "repo": session.DID, + "collection": "place.stream.livestream", + "rkey": aturi.RecordKey().String(), + }, nil, &getOutput) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting livestream record", err) + } + swapRecord = getOutput.Cid + + lsInput := comatproto.RepoPutRecord_Input{ + Collection: "place.stream.livestream", + Record: &lexutil.LexiconTypeDecoder{Val: livestreamRecord}, + Rkey: aturi.RecordKey().String(), + Repo: session.DID, + SwapRecord: swapRecord, + } + var lsOutput comatproto.RepoPutRecord_Output + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, lsInput, &lsOutput) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error updating livestream record", err) + } + + return &placestream.LiveStopLivestream_Output{ + Uri: lsOutput.Uri, + Cid: lsOutput.Cid, + }, nil +} diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index de19be2d..2b67de33 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -290,6 +290,8 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.GET("/xrpc/place.stream.live.getRecommendations", s.HandlePlaceStreamLiveGetRecommendations) e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) e.GET("/xrpc/place.stream.live.searchActorsTypeahead", s.HandlePlaceStreamLiveSearchActorsTypeahead) + e.POST("/xrpc/place.stream.live.startLivestream", s.HandlePlaceStreamLiveStartLivestream) + e.POST("/xrpc/place.stream.live.stopLivestream", s.HandlePlaceStreamLiveStopLivestream) e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock) e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate) e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock) @@ -524,6 +526,42 @@ func (s *Server) HandlePlaceStreamLiveSearchActorsTypeahead(c echo.Context) erro return c.JSON(200, out) } +func (s *Server) HandlePlaceStreamLiveStartLivestream(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveStartLivestream") + defer span.End() + + var body placestream.LiveStartLivestream_Input + if err := c.Bind(&body); err != nil { + return err + } + var out *placestream.LiveStartLivestream_Output + var handleErr error + // func (s *Server) handlePlaceStreamLiveStartLivestream(ctx context.Context,body *placestream.LiveStartLivestream_Input) (*placestream.LiveStartLivestream_Output, error) + out, handleErr = s.handlePlaceStreamLiveStartLivestream(ctx, &body) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + +func (s *Server) HandlePlaceStreamLiveStopLivestream(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveStopLivestream") + defer span.End() + + var body placestream.LiveStopLivestream_Input + if err := c.Bind(&body); err != nil { + return err + } + var out *placestream.LiveStopLivestream_Output + var handleErr error + // func (s *Server) handlePlaceStreamLiveStopLivestream(ctx context.Context,body *placestream.LiveStopLivestream_Input) (*placestream.LiveStopLivestream_Output, error) + out, handleErr = s.handlePlaceStreamLiveStopLivestream(ctx, &body) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamModerationCreateBlock(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreateBlock") defer span.End() diff --git a/pkg/streamplace/livestartLivestream.go b/pkg/streamplace/livestartLivestream.go new file mode 100644 index 00000000..1d697e1d --- /dev/null +++ b/pkg/streamplace/livestartLivestream.go @@ -0,0 +1,38 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.live.startLivestream + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// LiveStartLivestream_Input is the input argument to a place.stream.live.startLivestream call. +type LiveStartLivestream_Input struct { + // createBlueskyPost: Whether to create a Bluesky post announcing the livestream. + CreateBlueskyPost *bool `json:"createBlueskyPost,omitempty" cborgen:"createBlueskyPost,omitempty"` + Livestream *Livestream `json:"livestream" cborgen:"livestream"` + // streamer: The DID of the streamer. + Streamer string `json:"streamer" cborgen:"streamer"` +} + +// LiveStartLivestream_Output is the output of a place.stream.live.startLivestream call. +type LiveStartLivestream_Output struct { + // cid: The CID of the livestream record. + Cid string `json:"cid" cborgen:"cid"` + // uri: The URI of the livestream record. + Uri string `json:"uri" cborgen:"uri"` +} + +// LiveStartLivestream calls the XRPC method "place.stream.live.startLivestream". +func LiveStartLivestream(ctx context.Context, c lexutil.LexClient, input *LiveStartLivestream_Input) (*LiveStartLivestream_Output, error) { + var out LiveStartLivestream_Output + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.live.startLivestream", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/pkg/streamplace/livestopLivestream.go b/pkg/streamplace/livestopLivestream.go new file mode 100644 index 00000000..40d943be --- /dev/null +++ b/pkg/streamplace/livestopLivestream.go @@ -0,0 +1,33 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.live.stopLivestream + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// LiveStopLivestream_Input is the input argument to a place.stream.live.stopLivestream call. +type LiveStopLivestream_Input struct { +} + +// LiveStopLivestream_Output is the output of a place.stream.live.stopLivestream call. +type LiveStopLivestream_Output struct { + // cid: The new CID of the stopped livestream record. + Cid string `json:"cid" cborgen:"cid"` + // uri: The URI of the stopped livestream record. + Uri string `json:"uri" cborgen:"uri"` +} + +// LiveStopLivestream calls the XRPC method "place.stream.live.stopLivestream". +func LiveStopLivestream(ctx context.Context, c lexutil.LexClient, input *LiveStopLivestream_Input) (*LiveStopLivestream_Output, error) { + var out LiveStopLivestream_Output + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.live.stopLivestream", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} -- 2.51.2 From 938ae09e4c111a7af973a54a8c19652e5a1064a4 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 26 Feb 2026 15:31:30 -0800 Subject: [PATCH 47/49] mobile-golive: handle case where you already have an active livestream --- js/app/components/mobile/player.tsx | 79 +++++++++++++++++++ js/components/src/hooks/useLivestreamInfo.ts | 7 +- .../src/livestream-store/livestream-state.tsx | 2 + .../src/livestream-store/livestream-store.tsx | 2 + .../src/streamplace-store/stream.tsx | 6 +- pkg/director/stream_session.go | 16 +--- pkg/statedb/queue_processor.go | 3 + pkg/statedb/task.go | 4 +- 8 files changed, 101 insertions(+), 18 deletions(-) diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx index 049f8387..c42559b4 100644 --- a/js/app/components/mobile/player.tsx +++ b/js/app/components/mobile/player.tsx @@ -9,6 +9,8 @@ import { PlayerUI, RotationProvider, Text, + useLivestream, + useLivestreamInfo, useLivestreamStore, usePlayerDimensions, usePlayerStore, @@ -131,6 +133,9 @@ function PlayerWithProvider( }; }, []); + const livestream = useLivestream(); + const localLivestreamURI = useLivestreamStore((x) => x.localLivestreamURI); + if (isStreamingElsewhere) { return ( @@ -190,6 +195,10 @@ function PlayerWithProvider( ); } + if (props.ingest && livestream && livestream.uri !== localLivestreamURI) { + return ; + } + const defaultHandleTeleport = (targetHandle: string, targetDID: string) => { navigation.navigate("Home", { screen: "Stream", @@ -412,3 +421,73 @@ export function PlayerInner( ); } + +export function LivestreamWarning() { + const livestream = useLivestream(); + const localLivestreamURI = useLivestreamStore((x) => x.localLivestreamURI); + const { toggleStopStream } = useLivestreamInfo(); + const navigation = useNavigation(); + const setLocalLivestreamURI = useLivestreamStore( + (x) => x.setLocalLivestreamURI, + ); + + const [loading, setLoading] = useState(false); + + if (livestream && livestream.uri !== localLivestreamURI) { + return ( + + You have an active livestream! + "{livestream.record.title}" + + + + + ); + } +} diff --git a/js/components/src/hooks/useLivestreamInfo.ts b/js/components/src/hooks/useLivestreamInfo.ts index 77dd9688..cb63b518 100644 --- a/js/components/src/hooks/useLivestreamInfo.ts +++ b/js/components/src/hooks/useLivestreamInfo.ts @@ -7,7 +7,9 @@ export function useLivestreamInfo(url?: string) { const ingest = usePlayerStore((x) => x.ingestConnectionState); const profile = useLivestreamStore((x) => x.profile); const endLivestream = useEndLivestream(); - + const setLocalLivestreamURI = useLivestreamStore( + (x) => x.setLocalLivestreamURI, + ); const createStreamRecord = useCreateStreamRecord(); const [title, setTitle] = useState(""); @@ -19,10 +21,11 @@ export function useLivestreamInfo(url?: string) { if (title !== "") { setRecordSubmitted(true); // Create the livestream record with title and custom url if available - await createStreamRecord({ + const { uri } = await createStreamRecord({ title, canonicalUrl: url || undefined, }); + setLocalLivestreamURI(uri); } } catch (error) { console.error("Error creating livestream:", error); diff --git a/js/components/src/livestream-store/livestream-state.tsx b/js/components/src/livestream-store/livestream-state.tsx index c41976b4..9f7a4ba6 100644 --- a/js/components/src/livestream-store/livestream-state.tsx +++ b/js/components/src/livestream-store/livestream-state.tsx @@ -32,6 +32,8 @@ export interface LivestreamState { setModerationPermissions: ( permissions: PlaceStreamModerationPermission.Record[], ) => void; + localLivestreamURI: string | null; + setLocalLivestreamURI: (uri: string | null) => void; } export interface LivestreamProblem { diff --git a/js/components/src/livestream-store/livestream-store.tsx b/js/components/src/livestream-store/livestream-store.tsx index eff4e30d..97e4ddb7 100644 --- a/js/components/src/livestream-store/livestream-store.tsx +++ b/js/components/src/livestream-store/livestream-store.tsx @@ -29,6 +29,8 @@ export const makeLivestreamStore = (): StoreApi => { hasReceivedSegment: false, moderationPermissions: [], setModerationPermissions: (perms) => set({ moderationPermissions: perms }), + localLivestreamURI: null, + setLocalLivestreamURI: (uri) => set({ localLivestreamURI: uri }), })); }; diff --git a/js/components/src/streamplace-store/stream.tsx b/js/components/src/streamplace-store/stream.tsx index dceb13f1..ec5881ea 100644 --- a/js/components/src/streamplace-store/stream.tsx +++ b/js/components/src/streamplace-store/stream.tsx @@ -197,7 +197,11 @@ export function useCreateStreamRecord() { createBlueskyPost: submitPost, }); - return output; + if (!output.success) { + throw new Error("Failed to start livestream"); + } + + return output.data; }; } diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 8ff036b0..86ecd11f 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -468,7 +468,9 @@ var livestreamUpdateInterval = time.Second * 30 func (ss *StreamSession) UpdateLivestream(ctx context.Context, repoDID string) { select { case ss.livestreamUpdateChan <- struct{}{}: + log.Warn(ctx, "livestream update signal sent") default: + log.Warn(ctx, "livestream update channel full, signal already pending") // Channel full, signal already pending } } @@ -482,7 +484,7 @@ func (ss *StreamSession) livestreamUpdateLoop(ctx context.Context, repoDID strin return nil case <-ss.livestreamUpdateChan: if time.Since(ss.lastLivestreamTime) < livestreamUpdateInterval { - log.Debug(ctx, "not updating livestream, last livestream was less than 30 seconds ago") + log.Warn(ctx, "not updating livestream, last livestream was less than 30 seconds ago") continue } if err := ss.doUpdateLivestream(ctx, repoDID); err != nil { @@ -512,18 +514,6 @@ func (ss *StreamSession) doUpdateLivestream(ctx context.Context, repoDID string) if !ok { return fmt.Errorf("livestream is not a streamplace livestream") } - if lsvr.LastSeenAt == nil { - log.Debug(ctx, "livestream has no last seen at, skipping update") - return nil - } - lastSeenTime, err := time.Parse(time.RFC3339, *lsvr.LastSeenAt) - if err != nil { - return fmt.Errorf("could not parse last seen at: %w", err) - } - if time.Since(lastSeenTime) > 5*time.Minute { - log.Debug(ctx, "livestream is inactive, skipping update", "lastSeenAt", lastSeenTime) - return nil - } aturi, err := syntax.ParseATURI(lastLivestream.URI) if err != nil { diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go index 858fe23c..64a6fa48 100644 --- a/pkg/statedb/queue_processor.go +++ b/pkg/statedb/queue_processor.go @@ -78,6 +78,9 @@ func (state *StatefulDB) processTask(ctx context.Context, task *AppTask) error { } func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task *AppTask) error { + ctx = log.WithLogValues(ctx, "func", "processFinalizeLivestreamTask") + log.Debug(ctx, "processing finalize livestream task") + log.Warn(ctx, "processing finalize livestream task") var finalizeLivestreamTask FinalizeLivestreamTask if err := json.Unmarshal(task.Payload, &finalizeLivestreamTask); err != nil { return err diff --git a/pkg/statedb/task.go b/pkg/statedb/task.go index e971b65e..dadca32e 100644 --- a/pkg/statedb/task.go +++ b/pkg/statedb/task.go @@ -105,8 +105,8 @@ func (state *StatefulDB) DequeueTask(ctx context.Context, workerID string, taskT err := state.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { query := tx.Where("status = ?", TaskStatusPending). Where("try_count < max_tries"). - Where("(lock_expires IS NULL OR lock_expires < ?)", time.Now()). - Where("(scheduled_at IS NULL OR scheduled_at <= ?)", time.Now()) + Where("(lock_expires IS NULL OR lock_expires < ?)", time.Now().UTC()). + Where("(scheduled_at IS NULL OR scheduled_at <= ?)", time.Now().UTC()) if len(taskTypes) > 0 { query = query.Where("type IN ?", taskTypes) -- 2.51.2 From 8f00051e671b02e041e6e2aeaea81713ada0b599 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 27 Feb 2026 12:16:55 -0800 Subject: [PATCH 48/49] fix thumbnail in test --- pkg/api/playback.go | 46 ++++++++++++++++++----------------- pkg/media/webrtc_playback2.go | 2 +- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/pkg/api/playback.go b/pkg/api/playback.go index 87b49c8e..ab4500b6 100644 --- a/pkg/api/playback.go +++ b/pkg/api/playback.go @@ -273,28 +273,30 @@ func (a *StreamplaceAPI) HandleThumbnailPlayback(ctx context.Context) httprouter errors.WriteHTTPNotFound(w, "user not found", err) return } - ls, err := a.Model.GetLatestLivestreamForRepo(user) - if err != nil { - errors.WriteHTTPInternalServerError(w, "could not get livestream", err) - return - } - if ls == nil { - errors.WriteHTTPNotFound(w, "livestream not found", err) - return - } - lsrv, err := ls.ToLivestreamView() - if err != nil { - errors.WriteHTTPInternalServerError(w, "could not marshal livestream", err) - return - } - lsr, ok := lsrv.Record.Val.(*streamplace.Livestream) - if !ok { - errors.WriteHTTPInternalServerError(w, "livestream is not a streamplace livestream", nil) - return - } - if lsr.EndedAt != nil { - errors.WriteHTTPNotFound(w, "livestream has ended", nil) - return + if !a.CLI.WideOpen { + ls, err := a.Model.GetLatestLivestreamForRepo(user) + if err != nil { + errors.WriteHTTPInternalServerError(w, "could not get livestream", err) + return + } + if ls == nil { + errors.WriteHTTPNotFound(w, "livestream not found", err) + return + } + lsrv, err := ls.ToLivestreamView() + if err != nil { + errors.WriteHTTPInternalServerError(w, "could not marshal livestream", err) + return + } + lsr, ok := lsrv.Record.Val.(*streamplace.Livestream) + if !ok { + errors.WriteHTTPInternalServerError(w, "livestream is not a streamplace livestream", nil) + return + } + if lsr.EndedAt != nil { + errors.WriteHTTPNotFound(w, "livestream has ended", nil) + return + } } thumb, err := a.LocalDB.LatestThumbnailForUser(user) if err != nil { diff --git a/pkg/media/webrtc_playback2.go b/pkg/media/webrtc_playback2.go index edb74cf1..fcc541d1 100644 --- a/pkg/media/webrtc_playback2.go +++ b/pkg/media/webrtc_playback2.go @@ -93,7 +93,7 @@ func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendit return case file := <-segChan.C: log.Debug(ctx, "got segment", "file", file.Filepath) - if !file.Published && viewer != user { + if !file.Published && viewer != user && !mm.cli.WideOpen { log.Warn(ctx, "segment is not published and viewer is not the user", "viewer", viewer, "user", user) continue } -- 2.51.2 From 7442b0f8bb75edfde233c1386118390f1af31450 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 27 Feb 2026 15:50:51 -0800 Subject: [PATCH 49/49] wide open --- js/desktop/src/tests/playback-test.ts | 1 + js/desktop/src/tests/resume-loop-test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/js/desktop/src/tests/playback-test.ts b/js/desktop/src/tests/playback-test.ts index eecac38c..27791b22 100644 --- a/js/desktop/src/tests/playback-test.ts +++ b/js/desktop/src/tests/playback-test.ts @@ -12,6 +12,7 @@ export const playbackTest: E2ETest = { env: { ...testEnv.env, SP_TEST_STREAM: "true", + SP_WIDE_OPEN: "true", }, }; }, diff --git a/js/desktop/src/tests/resume-loop-test.ts b/js/desktop/src/tests/resume-loop-test.ts index a4ab8345..9f3334ca 100644 --- a/js/desktop/src/tests/resume-loop-test.ts +++ b/js/desktop/src/tests/resume-loop-test.ts @@ -19,6 +19,7 @@ export const resumeLoopTest: E2ETest = { env: { ...testEnv.env, SP_TEST_STREAM: "true", + SP_WIDE_OPEN: "true", }, }; },