Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+4745 -1806
+6
.prettierrc
··· 6 "options": { 7 "proseWrap": "preserve" 8 } 9 } 10 ], 11 "plugins": ["prettier-plugin-organize-imports"]
··· 6 "options": { 7 "proseWrap": "preserve" 8 } 9 + }, 10 + { 11 + "files": "*.md", 12 + "options": { 13 + "proseWrap": "preserve" 14 + } 15 } 16 ], 17 "plugins": ["prettier-plugin-organize-imports"]
+5 -4
Makefile
··· 385 && sed -i.bak 's/PlaceStreamMultistreamTarget\.Main/PlaceStreamMultistreamTarget\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 386 && sed -i.bak 's/PlaceStreamChatProfile\.Main/PlaceStreamChatProfile\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 387 && for x in $$(find ./js/streamplace/src/lexicons -type f -name '*.ts'); do \ 388 - echo 'import { 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; \ 389 done \ 390 - && npx prettier --write $$(find ./js/streamplace/src/lexicons -type f -name '*.ts') \ 391 && find . | grep bak$$ | xargs rm 392 393 .PHONY: md-lexicons 394 md-lexicons: 395 - pnpm exec lexmd \ 396 ./lexicons \ 397 .build/temp \ 398 subprojects/atproto/lexicons \ ··· 437 .PHONY: ci-lexicons 438 ci-lexicons: 439 $(MAKE) lexicons \ 440 - && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; exit 1; fi 441 442 # _______ ______ _____ _______ _____ _ _ _____ 443 # |__ __| ____|/ ____|__ __|_ _| \ | |/ ____|
··· 385 && sed -i.bak 's/PlaceStreamMultistreamTarget\.Main/PlaceStreamMultistreamTarget\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 386 && sed -i.bak 's/PlaceStreamChatProfile\.Main/PlaceStreamChatProfile\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 387 && for x in $$(find ./js/streamplace/src/lexicons -type f -name '*.ts'); do \ 388 + 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; \ 389 done \ 390 + && npx prettier --ignore-unknown --write $$(find ./js/streamplace/src/lexicons -type f -name '*.ts') \ 391 && find . | grep bak$$ | xargs rm 392 393 .PHONY: md-lexicons 394 md-lexicons: 395 + find "js/docs/src/content/docs/lex-reference" -type f -name '*.md' -delete \ 396 + && pnpm exec lexmd \ 397 ./lexicons \ 398 .build/temp \ 399 subprojects/atproto/lexicons \ ··· 438 .PHONY: ci-lexicons 439 ci-lexicons: 440 $(MAKE) lexicons \ 441 + && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; git diff; exit 1; fi 442 443 # _______ ______ _____ _______ _____ _ _ _____ 444 # |__ __| ____|/ ____|__ __|_ _| \ | |/ ____|
+2 -2
go.mod
··· 54 github.com/pion/webrtc/v4 v4.0.11 55 github.com/piprate/json-gold v0.5.0 56 github.com/prometheus/client_golang v1.23.0 57 github.com/rs/cors v1.11.1 58 github.com/samber/slog-http v1.4.0 59 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 60 github.com/slok/go-http-metrics v0.13.0 61 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 62 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 63 - github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f 64 github.com/stretchr/testify v1.11.1 65 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 66 github.com/whyrusleeping/cbor-gen v0.3.1 ··· 425 github.com/rabbitmq/amqp091-go v1.8.0 // indirect 426 github.com/rabbitmq/rabbitmq-stream-go-client v1.1.1 // indirect 427 github.com/raeperd/recvcheck v0.2.0 // indirect 428 - github.com/rivo/uniseg v0.4.7 // indirect 429 github.com/rogpeppe/go-internal v1.14.1 // indirect 430 github.com/rs/xid v1.5.0 // indirect 431 github.com/russross/blackfriday/v2 v2.1.0 // indirect
··· 54 github.com/pion/webrtc/v4 v4.0.11 55 github.com/piprate/json-gold v0.5.0 56 github.com/prometheus/client_golang v1.23.0 57 + github.com/rivo/uniseg v0.4.7 58 github.com/rs/cors v1.11.1 59 github.com/samber/slog-http v1.4.0 60 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 61 github.com/slok/go-http-metrics v0.13.0 62 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 63 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 64 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b 65 github.com/stretchr/testify v1.11.1 66 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 67 github.com/whyrusleeping/cbor-gen v0.3.1 ··· 426 github.com/rabbitmq/amqp091-go v1.8.0 // indirect 427 github.com/rabbitmq/rabbitmq-stream-go-client v1.1.1 // indirect 428 github.com/raeperd/recvcheck v0.2.0 // indirect 429 github.com/rogpeppe/go-internal v1.14.1 // indirect 430 github.com/rs/xid v1.5.0 // indirect 431 github.com/russross/blackfriday/v2 v2.1.0 // indirect
+2
go.sum
··· 1319 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1320 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= 1321 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1323 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1324 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
··· 1319 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1320 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= 1321 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= 1323 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1324 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1325 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1326 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+2 -1
js/app/components/follow-button.tsx
··· 123 disabled={isFollowing === null} 124 loading={isFollowing === null} 125 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />} 126 > 127 {isFollowing === null 128 ? "Loading..." 129 : isFollowing 130 - ? "Unfollow" 131 : "Follow"} 132 </Button> 133 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>}
··· 123 disabled={isFollowing === null} 124 loading={isFollowing === null} 125 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />} 126 + hoverStyle={isFollowing ? { backgroundColor: "#dc2626" } : undefined} 127 > 128 {isFollowing === null 129 ? "Loading..." 130 : isFollowing 131 + ? "Following" 132 : "Follow"} 133 </Button> 134 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>}
-2
js/app/components/live-dashboard/bento-grid.tsx
··· 155 streamTitle={ 156 profile?.displayName || profile?.handle || "Live Stream" 157 } 158 - viewers={viewers || 0} 159 uptime={getUptime()} 160 bitrate={getBitrate()} 161 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} ··· 235 streamTitle={ 236 profile?.displayName || profile?.handle || "Live Stream" 237 } 238 - viewers={viewers || 0} 239 uptime={getUptime()} 240 bitrate={getBitrate()} 241 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0}
··· 155 streamTitle={ 156 profile?.displayName || profile?.handle || "Live Stream" 157 } 158 uptime={getUptime()} 159 bitrate={getBitrate()} 160 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} ··· 234 streamTitle={ 235 profile?.displayName || profile?.handle || "Live Stream" 236 } 237 uptime={getUptime()} 238 bitrate={getBitrate()} 239 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0}
+7 -3
js/app/components/live-dashboard/live-selector.tsx
··· 57 if (selectedMode === "streamkey") { 58 return ( 59 <View flex={1} style={[flex.grow[1], { width: "100%" }]}> 60 - <View padding="md" direction="row" justify="between" align="end"> 61 - <Button variant="ghost" onPress={() => setSelectedMode(null)}> 62 ← Back 63 </Button> 64 <Text variant="h4" weight="bold"> 65 Stream from OBS 66 </Text> 67 - <Button variant="ghost" style={{ opacity: 0 }}> 68 ← Back 69 </Button> 70 </View>
··· 57 if (selectedMode === "streamkey") { 58 return ( 59 <View flex={1} style={[flex.grow[1], { width: "100%" }]}> 60 + <View padding="md" direction="row" justify="around" align="start"> 61 + <Button 62 + variant="ghost" 63 + width="min" 64 + onPress={() => setSelectedMode(null)} 65 + > 66 ← Back 67 </Button> 68 <Text variant="h4" weight="bold"> 69 Stream from OBS 70 </Text> 71 + <Button variant="ghost" width="min" style={{ opacity: 0 }}> 72 ← Back 73 </Button> 74 </View>
+82 -14
js/app/components/live-dashboard/livestream-panel.tsx
··· 1 import { 2 Button, 3 Checkbox, 4 ContentMetadataForm, 5 Dashboard, 6 formatHandle, 7 formatHandleWithAt, 8 Input, 9 Textarea, 10 Tooltip, 11 useCreateStreamRecord, ··· 15 useUrl, 16 zero, 17 } from "@streamplace/components"; 18 - import { ImagePlus, X } from "lucide-react-native"; 19 import { useCallback, useEffect, useMemo, useState } from "react"; 20 import { 21 Image, 22 Platform, 23 ScrollView, 24 - Text, 25 TouchableOpacity, 26 View, 27 } from "react-native"; ··· 80 selectedImage, 81 onImageSelect, 82 onImageRemove, 83 }: { 84 selectedImage?: string | File | Blob; 85 onImageSelect?: () => void; 86 onImageRemove?: () => void; 87 }) => { 88 const imageUrl = useMemo(() => { 89 if (!selectedImage) return undefined; ··· 150 </TouchableOpacity> 151 </View> 152 ) : ( 153 - <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 154 - <ImagePlus size={48} color="#6b7280" /> 155 - <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 156 - Add thumbnail image 157 - </Text> 158 - <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 159 - Optional • JPG, PNG up to 975KB 160 - </Text> 161 - </TouchableOpacity> 162 )} 163 </View> 164 ); 165 }; ··· 185 186 const [createPost, setCreatePost] = useState(true); 187 const [sendPushNotification, setSendPushNotification] = useState(true); 188 - const [canonicalUrl, setCanonicalUrl] = useState<string>( 189 - livestream?.record.canonicalUrl || "", 190 - ); 191 const defaultCanonicalUrl = useMemo(() => { 192 return `${url}/${profile && formatHandle(profile)}`; 193 }, [url, profile?.handle]); ··· 196 if (!livestream) { 197 return; 198 } 199 if ( 200 livestream.record.canonicalUrl && 201 livestream.record.canonicalUrl !== defaultCanonicalUrl 202 ) { 203 setCanonicalUrl(livestream.record.canonicalUrl); 204 } 205 if ( 206 typeof livestream.record.notificationSettings?.pushNotification === 207 "boolean" ··· 210 livestream.record.notificationSettings.pushNotification, 211 ); 212 } 213 setCreatePost(typeof livestream.record.post !== "undefined"); 214 }, [livestream, defaultCanonicalUrl]); 215 ··· 319 const handleImageRemove = useCallback(() => { 320 setSelectedImage(undefined); 321 }, []); 322 323 const disabled = useMemo( 324 () => !userIsLive || loading || title.trim() === "", ··· 569 selectedImage={selectedImage} 570 onImageSelect={handleImageSelect} 571 onImageRemove={handleImageRemove} 572 /> 573 )} 574
··· 1 import { 2 + Admonition, 3 Button, 4 Checkbox, 5 ContentMetadataForm, 6 Dashboard, 7 formatHandle, 8 formatHandleWithAt, 9 + getBlob, 10 Input, 11 + resolveDIDDocument, 12 + Text, 13 Textarea, 14 Tooltip, 15 useCreateStreamRecord, ··· 19 useUrl, 20 zero, 21 } from "@streamplace/components"; 22 + import { ArrowRight, ImagePlus, X } from "lucide-react-native"; 23 import { useCallback, useEffect, useMemo, useState } from "react"; 24 import { 25 Image, 26 Platform, 27 + Pressable, 28 ScrollView, 29 TouchableOpacity, 30 View, 31 } from "react-native"; ··· 84 selectedImage, 85 onImageSelect, 86 onImageRemove, 87 + onUseLastImage, 88 + hasLastImage, 89 + onGoToMetadata, 90 }: { 91 selectedImage?: string | File | Blob; 92 onImageSelect?: () => void; 93 onImageRemove?: () => void; 94 + onUseLastImage?: () => void; 95 + hasLastImage?: boolean; 96 + onGoToMetadata?: () => void; 97 }) => { 98 const imageUrl = useMemo(() => { 99 if (!selectedImage) return undefined; ··· 160 </TouchableOpacity> 161 </View> 162 ) : ( 163 + <> 164 + <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 165 + <ImagePlus size={48} color="#6b7280" /> 166 + <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 167 + Add thumbnail image 168 + </Text> 169 + <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 170 + Optional • JPG, PNG up to 975KB 171 + </Text> 172 + </TouchableOpacity> 173 + {hasLastImage && ( 174 + <Button 175 + variant="secondary" 176 + size="sm" 177 + onPress={onUseLastImage} 178 + style={[{ marginTop: 8 }]} 179 + > 180 + <Text style={[text.gray[300], { fontSize: 14 }]}> 181 + Use Last Image 182 + </Text> 183 + </Button> 184 + )} 185 + </> 186 )} 187 + <View style={{ marginTop: 8 }}> 188 + <Admonition variant="info" size="sm"> 189 + <Text size="sm"> 190 + You are required to disclose if your content is not suitable for 191 + certain viewers. 192 + </Text> 193 + <Pressable onPress={onGoToMetadata}> 194 + <Text size="sm" color={zero.colors.blue[400]}> 195 + Go to the metadata page{" "} 196 + <ArrowRight size="14" style={{ marginVertical: -2 }} /> 197 + </Text> 198 + </Pressable> 199 + </Admonition> 200 + </View> 201 </View> 202 ); 203 }; ··· 223 224 const [createPost, setCreatePost] = useState(true); 225 const [sendPushNotification, setSendPushNotification] = useState(true); 226 + const [canonicalUrl, setCanonicalUrl] = useState<string>(""); 227 const defaultCanonicalUrl = useMemo(() => { 228 return `${url}/${profile && formatHandle(profile)}`; 229 }, [url, profile?.handle]); ··· 232 if (!livestream) { 233 return; 234 } 235 + 236 + // Prefill title with previous stream's title 237 + if (livestream.record.title) { 238 + setTitle(livestream.record.title); 239 + } 240 + 241 + // Prefill canonical URL 242 if ( 243 livestream.record.canonicalUrl && 244 livestream.record.canonicalUrl !== defaultCanonicalUrl 245 ) { 246 setCanonicalUrl(livestream.record.canonicalUrl); 247 } 248 + 249 + // Prefill notification settings 250 if ( 251 typeof livestream.record.notificationSettings?.pushNotification === 252 "boolean" ··· 255 livestream.record.notificationSettings.pushNotification, 256 ); 257 } 258 + 259 + // Prefill post creation preference 260 setCreatePost(typeof livestream.record.post !== "undefined"); 261 }, [livestream, defaultCanonicalUrl]); 262 ··· 366 const handleImageRemove = useCallback(() => { 367 setSelectedImage(undefined); 368 }, []); 369 + 370 + const handleUseLastImage = useCallback(async () => { 371 + if (!livestream?.record.thumb) return; 372 + 373 + try { 374 + const did = livestream.uri.split("/")[2]; 375 + const cid = (livestream.record.thumb.ref as any).$link; 376 + 377 + const didDoc = await resolveDIDDocument(did); 378 + const blob = await getBlob(did, cid, didDoc); 379 + setSelectedImage(blob); 380 + } catch (error) { 381 + console.error("Failed to fetch last image:", error); 382 + toast.show("Error", "Failed to load previous thumbnail", { 383 + duration: 3, 384 + }); 385 + } 386 + }, [livestream, toast]); 387 388 const disabled = useMemo( 389 () => !userIsLive || loading || title.trim() === "", ··· 634 selectedImage={selectedImage} 635 onImageSelect={handleImageSelect} 636 onImageRemove={handleImageRemove} 637 + onUseLastImage={handleUseLastImage} 638 + hasLastImage={!!livestream?.record.thumb} 639 + onGoToMetadata={() => handleModeChange("metadata")} 640 /> 641 )} 642
+23 -3
js/app/components/live-dashboard/stream-key.tsx
··· 8 useTheme, 9 useToast, 10 View, 11 } from "@streamplace/components"; 12 import Loading from "components/loading/loading"; 13 import { Clipboard, ClipboardCheck } from "lucide-react-native"; ··· 68 <View fullWidth style={{ maxWidth: 600 }}> 69 <FormRow> 70 <Button 71 variant={protocol !== "rtmp" ? "secondary" : "primary"} 72 onPress={() => setProtocol("rtmp")} 73 style={{ ··· 78 RTMP 79 </Button> 80 <Button 81 variant={protocol !== "whip" ? "secondary" : "primary"} 82 onPress={() => setProtocol("whip")} 83 style={{ ··· 93 <FormRow> 94 <Label>Output Settings</Label> 95 <Content> 96 - <Body> 97 <Text>Output mode: Advanced</Text> 98 <Text> 99 Keyframe Interval: <Code>1s</Code> 100 </Text> 101 <Text> 102 - x264 Options: <Code>bframes=0</Code> 103 </Text> 104 - </Body> 105 </Content> 106 </FormRow> 107 </View> ··· 271 selectTextOnFocus={true} 272 /> 273 <Button 274 onPress={handleCopy} 275 style={[ 276 {
··· 8 useTheme, 9 useToast, 10 View, 11 + zero, 12 } from "@streamplace/components"; 13 import Loading from "components/loading/loading"; 14 import { Clipboard, ClipboardCheck } from "lucide-react-native"; ··· 69 <View fullWidth style={{ maxWidth: 600 }}> 70 <FormRow> 71 <Button 72 + width="min" 73 variant={protocol !== "rtmp" ? "secondary" : "primary"} 74 onPress={() => setProtocol("rtmp")} 75 style={{ ··· 80 RTMP 81 </Button> 82 <Button 83 + width="min" 84 variant={protocol !== "whip" ? "secondary" : "primary"} 85 onPress={() => setProtocol("whip")} 86 style={{ ··· 96 <FormRow> 97 <Label>Output Settings</Label> 98 <Content> 99 + <View style={[zero.mt[2]]}> 100 <Text>Output mode: Advanced</Text> 101 <Text> 102 Keyframe Interval: <Code>1s</Code> 103 </Text> 104 <Text> 105 + x264 Options:{" "} 106 + <Code 107 + style={{ 108 + paddingHorizontal: 4, 109 + backgroundColor: "#648800", 110 + }} 111 + > 112 + bframes=0 113 + </Code> 114 </Text> 115 + <Text 116 + underline 117 + style={{ 118 + fontWeight: "bold", 119 + }} 120 + > 121 + (Very important!) 122 + </Text> 123 + </View> 124 </Content> 125 </FormRow> 126 </View> ··· 290 selectTextOnFocus={true} 291 /> 292 <Button 293 + width="min" 294 onPress={handleCopy} 295 style={[ 296 {
+5 -1
js/app/components/live-dashboard/stream-monitor.tsx
··· 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 {isLive && userProfile ? ( 106 isStreamVisible ? ( 107 - <Player src={userProfile.did} name={userProfile.handle}> 108 <DesktopUi /> 109 <PlayerUI.ViewerLoadingOverlay /> 110 <OfflineCounter isMobile={true} />
··· 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 {isLive && userProfile ? ( 106 isStreamVisible ? ( 107 + <Player 108 + src={userProfile.did} 109 + name={userProfile.handle} 110 + muted={true} 111 + > 112 <DesktopUi /> 113 <PlayerUI.ViewerLoadingOverlay /> 114 <OfflineCounter isMobile={true} />
+11 -5
js/app/components/login/login-form.tsx
··· 20 21 interface LoginFormProps { 22 onSuccess?: () => void; 23 } 24 25 - export default function LoginForm({ onSuccess }: LoginFormProps) { 26 const { theme } = useTheme(); 27 const loginAction = useStore((state) => state.login); 28 const openLoginLink = useStore((state) => state.openLoginLink); ··· 74 }; 75 76 const onSignup = () => { 77 - // TODO: remove requirement for oauth-protected-resource in oatproxy 78 - loginAction("https://bsky.social", openLoginLink); 79 }; 80 81 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; ··· 284 ]} 285 > 286 <Button width="min" onPress={() => onSignup()} variant="ghost"> 287 - <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 288 </Button> 289 <Button 290 onPress={submit} ··· 293 width="min" 294 loading={loginState.loading} 295 > 296 - <Text style={[{ color: "white" }]}>Log in</Text> 297 </Button> 298 </View> 299 </>
··· 20 21 interface LoginFormProps { 22 onSuccess?: () => void; 23 + onCloseModal?: () => void; 24 + onOpenPdsModal?: () => void; 25 } 26 27 + export default function LoginForm({ 28 + onSuccess, 29 + onCloseModal, 30 + onOpenPdsModal, 31 + }: LoginFormProps) { 32 const { theme } = useTheme(); 33 const loginAction = useStore((state) => state.login); 34 const openLoginLink = useStore((state) => state.openLoginLink); ··· 80 }; 81 82 const onSignup = () => { 83 + onCloseModal?.(); 84 + onOpenPdsModal?.(); 85 }; 86 87 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; ··· 290 ]} 291 > 292 <Button width="min" onPress={() => onSignup()} variant="ghost"> 293 + <Text style={[{ color: "white" }]}>Sign Up</Text> 294 </Button> 295 <Button 296 onPress={submit} ··· 299 width="min" 300 loading={loginState.loading} 301 > 302 + <Text style={[{ color: "white" }]}>Log In</Text> 303 </Button> 304 </View> 305 </>
+15 -2
js/app/components/login/login-modal.tsx
··· 6 interface LoginModalProps { 7 visible: boolean; 8 onClose: () => void; 9 } 10 11 - export default function LoginModal({ visible, onClose }: LoginModalProps) { 12 const { theme, zero: z } = useTheme(); 13 14 return ( 15 <Modal ··· 64 </TouchableOpacity> 65 </View> 66 67 - <LoginForm onSuccess={onClose} /> 68 </Pressable> 69 </View> 70 </Modal>
··· 6 interface LoginModalProps { 7 visible: boolean; 8 onClose: () => void; 9 + onOpenPdsModal: () => void; 10 } 11 12 + export default function LoginModal({ 13 + visible, 14 + onClose, 15 + onOpenPdsModal, 16 + }: LoginModalProps) { 17 const { theme, zero: z } = useTheme(); 18 + 19 + if (!visible) { 20 + return null; 21 + } 22 23 return ( 24 <Modal ··· 73 </TouchableOpacity> 74 </View> 75 76 + <LoginForm 77 + onSuccess={onClose} 78 + onCloseModal={onClose} 79 + onOpenPdsModal={onOpenPdsModal} 80 + /> 81 </Pressable> 82 </View> 83 </Modal>
+3 -1
js/app/components/login/login.tsx
··· 12 export default function Login() { 13 const { theme } = useTheme(); 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 15 const userProfile = useUserProfile(); 16 const navigation = useNavigation(); 17 const isReady = useIsReady(); ··· 26 27 // check for stored return route on mount 28 useEffect(() => { 29 storage.getItem("returnRoute").then((stored) => { 30 if (stored) { 31 try { ··· 103 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 104 Log in 105 </Text> 106 - <LoginForm /> 107 </View> 108 </View> 109 </ScrollView>
··· 12 export default function Login() { 13 const { theme } = useTheme(); 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 15 + const openPdsModal = useStore((state) => state.openPdsModal); 16 const userProfile = useUserProfile(); 17 const navigation = useNavigation(); 18 const isReady = useIsReady(); ··· 27 28 // check for stored return route on mount 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 31 storage.getItem("returnRoute").then((stored) => { 32 if (stored) { 33 try { ··· 105 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 106 Log in 107 </Text> 108 + <LoginForm onOpenPdsModal={openPdsModal} /> 109 </View> 110 </View> 111 </ScrollView>
+364
js/app/components/login/pds-host-selector-modal.tsx
···
··· 1 + import { 2 + Admonition, 3 + Button, 4 + Checkbox, 5 + Input, 6 + ResponsiveDialog, 7 + Trans as T, 8 + Text, 9 + useTheme, 10 + useTranslation, 11 + zero, 12 + } from "@streamplace/components"; 13 + import { Check, ExternalLink } from "lucide-react-native"; 14 + import React, { useState } from "react"; 15 + import { Linking, Pressable, View } from "react-native"; 16 + 17 + interface PdsHost { 18 + value: string; 19 + label: string; 20 + description: string; 21 + handlePolicyDocs?: string; 22 + terms: string; 23 + privacy: string; 24 + } 25 + 26 + const PDS_HOSTS = [ 27 + { 28 + value: "https://selfhosted.social", 29 + label: "selfhosted.social", 30 + description: "A popular community-run PDS", 31 + terms: "https://selfhosted.social/legal#terms", 32 + privacy: "https://selfhosted.social/legal", 33 + }, 34 + { 35 + // will redirect to https://bsky.social for sign in :thumb: 36 + value: "https://witchesbutter.us-west.host.bsky.network", 37 + label: "Bluesky", 38 + description: "The main Bluesky PDS instance", 39 + terms: "https://bsky.social/about/support/tos", 40 + privacy: "https://bsky.social/about/support/privacy-policy", 41 + }, 42 + { 43 + value: "https://blacksky.app", 44 + label: "Blacksky PDS", 45 + description: "A PDS service by Blacksky Algorithms", 46 + terms: "https://blackskyweb.xyz/about/support/tos", 47 + privacy: "https://blackskyweb.xyz/about/support/privacy-policy/", 48 + handlePolicyDocs: 49 + "https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services", 50 + }, 51 + { 52 + value: "https://pds.tophhie.cloud", 53 + label: "Tophhie Cloud", 54 + description: "A PDS service by Tophhie", 55 + terms: "https://blog.tophhie.cloud/atproto-tos/", 56 + privacy: "https://blog.tophhie.cloud/atproto-privacy-policy/", 57 + }, 58 + ]; 59 + 60 + // Shuffle the hosts 61 + // items with handle policies should never be first ! 62 + const shuffleArray = <T,>(array: T[]): T[] => { 63 + const arr = [...array]; 64 + for (let i = arr.length - 1; i > 0; i--) { 65 + const j = Math.floor(Math.random() * (i + 1)); 66 + [arr[i], arr[j]] = [arr[j], arr[i]]; 67 + } 68 + return arr; 69 + }; 70 + 71 + const SHUFFLED_PDS_HOSTS = (() => { 72 + const withPolicies = PDS_HOSTS.filter((h) => h.handlePolicyDocs); 73 + const [first, ...withoutPolicies] = PDS_HOSTS.filter( 74 + (h) => !h.handlePolicyDocs, 75 + ); 76 + return [first, ...shuffleArray(withPolicies.concat(withoutPolicies))]; 77 + })(); 78 + 79 + interface PdsHostSelectorModalProps { 80 + open: boolean; 81 + onOpenChange: (open: boolean) => void; 82 + onSubmit: (pdsHost: string) => void; 83 + } 84 + 85 + export const PdsHostSelectorModal: React.FC<PdsHostSelectorModalProps> = ({ 86 + open, 87 + onOpenChange, 88 + onSubmit, 89 + }) => { 90 + const [selectedHost, setSelectedHost] = useState<string | null>( 91 + SHUFFLED_PDS_HOSTS[0].value, 92 + ); 93 + const [customHost, setCustomHost] = useState<string>(""); 94 + const [useCustom, setUseCustom] = useState(false); 95 + const [handlePolicyChecked, hasCheckedHandlePolicy] = useState(false); 96 + 97 + const { theme } = useTheme(); 98 + const { t } = useTranslation(); 99 + 100 + const selectedHostObj = 101 + SHUFFLED_PDS_HOSTS.find((host) => host.value === selectedHost) || 102 + SHUFFLED_PDS_HOSTS[0]; 103 + 104 + const handleCancel = () => { 105 + setSelectedHost(SHUFFLED_PDS_HOSTS[0].value); 106 + setCustomHost(""); 107 + setUseCustom(false); 108 + onOpenChange(false); 109 + }; 110 + 111 + const handleSubmit = () => { 112 + const hostToUse = useCustom ? customHost : selectedHost; 113 + if (!hostToUse) return; 114 + 115 + onSubmit(hostToUse); 116 + handleCancel(); 117 + }; 118 + 119 + const handleLearnMore = () => { 120 + Linking.openURL("https://atproto.com/guides/self-hosting"); 121 + }; 122 + const handleTOS = () => { 123 + Linking.openURL(selectedHostObj.terms); 124 + }; 125 + const handlePrivacy = () => { 126 + Linking.openURL(selectedHostObj.privacy); 127 + }; 128 + 129 + const handleSelectHost = (value: string) => { 130 + setSelectedHost(value); 131 + setUseCustom(false); 132 + }; 133 + 134 + const handleSelectCustom = () => { 135 + setUseCustom(true); 136 + }; 137 + 138 + return ( 139 + <ResponsiveDialog 140 + open={open} 141 + onOpenChange={onOpenChange} 142 + showCloseButton={false} 143 + variant="default" 144 + size="sm" 145 + dismissible={false} 146 + position="center" 147 + > 148 + <View style={[{ maxWidth: 500 }]}> 149 + <View style={[zero.my[4]]}> 150 + <Text size="2xl" style={[zero.mb[2]]}> 151 + {t("pds-selector-title")} 152 + </Text> 153 + <Text style={[{ color: theme.colors.textMuted }]}> 154 + {t("pds-selector-description")} 155 + </Text> 156 + </View> 157 + <View style={[zero.pb[2]]}> 158 + {SHUFFLED_PDS_HOSTS.map((host, index) => ( 159 + <Pressable 160 + key={host.value} 161 + onPress={() => handleSelectHost(host.value)} 162 + style={[ 163 + zero.py[2], 164 + zero.px[3], 165 + zero.r.lg, 166 + { 167 + borderWidth: 1, 168 + borderColor: 169 + !useCustom && selectedHost === host.value 170 + ? theme.colors.primary 171 + : theme.colors.border, 172 + backgroundColor: 173 + !useCustom && selectedHost === host.value 174 + ? "rgba(0, 122, 255, 0.05)" 175 + : "transparent", 176 + }, 177 + index > 0 && zero.mt[2], 178 + ]} 179 + > 180 + <View 181 + style={[ 182 + zero.layout.flex.row, 183 + zero.layout.flex.spaceBetween, 184 + zero.layout.flex.alignCenter, 185 + ]} 186 + > 187 + <View style={[zero.flex[1]]}> 188 + <Text>{host.label}</Text> 189 + <Text 190 + style={[ 191 + zero.mt[1], 192 + { fontSize: 14, color: theme.colors.textMuted }, 193 + ]} 194 + > 195 + {host.description} 196 + </Text> 197 + </View> 198 + {!useCustom && selectedHost === host.value && ( 199 + <Check size={20} color={theme.colors.primary} /> 200 + )} 201 + </View> 202 + </Pressable> 203 + ))} 204 + 205 + <Pressable 206 + onPress={handleSelectCustom} 207 + style={[ 208 + zero.py[2], 209 + zero.px[3], 210 + zero.r.lg, 211 + zero.mt[2], 212 + { 213 + borderWidth: 1, 214 + borderColor: useCustom 215 + ? theme.colors.primary 216 + : theme.colors.border, 217 + backgroundColor: useCustom 218 + ? "rgba(0, 122, 255, 0.05)" 219 + : "transparent", 220 + }, 221 + ]} 222 + > 223 + <View 224 + style={[ 225 + zero.layout.flex.row, 226 + zero.layout.flex.spaceBetween, 227 + zero.layout.flex.alignCenter, 228 + ]} 229 + > 230 + <View style={[zero.flex[1]]}> 231 + <Text>{t("pds-selector-custom-label")}</Text> 232 + <Text 233 + style={[ 234 + zero.mt[1], 235 + { fontSize: 14, color: theme.colors.textMuted }, 236 + ]} 237 + > 238 + {t("pds-selector-custom-description")} 239 + </Text> 240 + </View> 241 + {useCustom && <Check size={20} color={theme.colors.primary} />} 242 + </View> 243 + </Pressable> 244 + 245 + <View style={[zero.mt[4]]}> 246 + <Pressable 247 + onPress={handleLearnMore} 248 + style={[ 249 + zero.layout.flex.row, 250 + zero.gap.all[1], 251 + zero.layout.flex.alignCenter, 252 + ]} 253 + > 254 + <Text style={[{ color: theme.colors.ring, fontSize: 14 }]}> 255 + {t("pds-selector-learn-more")} 256 + </Text> 257 + <ExternalLink size={16} color={theme.colors.ring} /> 258 + </Pressable> 259 + </View> 260 + 261 + {useCustom && ( 262 + <View style={[zero.mt[3]]}> 263 + <Text style={[zero.mb[2], { color: theme.colors.textMuted }]}> 264 + {t("pds-selector-custom-url-label")} 265 + </Text> 266 + <Input 267 + value={customHost} 268 + onChangeText={setCustomHost} 269 + placeholder={t("pds-selector-custom-url-placeholder")} 270 + autoCapitalize="none" 271 + autoCorrect={false} 272 + keyboardType="url" 273 + /> 274 + </View> 275 + )} 276 + <Admonition variant="info" style={[zero.my[4]] as any}> 277 + <Text style={[zero.mb[2]]}>{t("pds-selector-info")}</Text> 278 + {!useCustom && ( 279 + <Text style={[zero.mb[2]]}> 280 + <T 281 + i18nKey="pds-selector-read-policies" 282 + values={{ label: selectedHostObj?.label }} 283 + components={{ 284 + tosLink: ( 285 + <Text 286 + onPress={handleTOS} 287 + style={[{ color: theme.colors.ring }]} 288 + /> 289 + ), 290 + privacyLink: ( 291 + <Text 292 + onPress={handlePrivacy} 293 + style={[{ color: theme.colors.ring }]} 294 + /> 295 + ), 296 + }} 297 + /> 298 + </Text> 299 + )} 300 + </Admonition> 301 + {!useCustom && selectedHostObj.handlePolicyDocs && ( 302 + <View 303 + style={[ 304 + zero.layout.flex.row, 305 + zero.layout.flex.align.center, 306 + zero.layout.flex.justify.start, 307 + zero.gap.all[2], 308 + zero.mb[4], 309 + zero.mt[2], 310 + ]} 311 + > 312 + <Checkbox 313 + checked={handlePolicyChecked} 314 + onCheckedChange={hasCheckedHandlePolicy} 315 + /> 316 + <Text style={[zero.flex[1]]}> 317 + <T 318 + i18nKey="pds-selector-handle-policy-checkbox" 319 + components={{ 320 + policyLink: ( 321 + <Text 322 + onPress={() => 323 + Linking.openURL(selectedHostObj.handlePolicyDocs!) 324 + } 325 + style={[{ color: theme.colors.ring }]} 326 + > 327 + {selectedHostObj.label} guidelines and handle policy 328 + </Text> 329 + ), 330 + }} 331 + /> 332 + </Text> 333 + </View> 334 + )} 335 + </View> 336 + <View 337 + style={[ 338 + zero.flex[1], 339 + zero.layout.flex.row, 340 + zero.layout.flex.justify.end, 341 + zero.gap.all[2], 342 + ]} 343 + > 344 + <Button width="min" variant="secondary" onPress={handleCancel}> 345 + <Text>{t("cancel")}</Text> 346 + </Button> 347 + <Button 348 + width="min" 349 + variant="primary" 350 + onPress={handleSubmit} 351 + disabled={ 352 + (useCustom && !customHost.trim()) || 353 + (!handlePolicyChecked && !!selectedHostObj.handlePolicyDocs) 354 + } 355 + > 356 + <Text>{t("continue")}</Text> 357 + </Button> 358 + </View> 359 + </View> 360 + </ResponsiveDialog> 361 + ); 362 + }; 363 + 364 + export default PdsHostSelectorModal;
+2
js/app/components/mobile/bottom-metadata.tsx
··· 17 import FollowButton from "components/follow-button"; 18 import { ChevronLeft, ChevronRight } from "lucide-react-native"; 19 import { Image, Linking, Pressable, View } from "react-native"; 20 const { gap, px, py, colors } = zero; 21 22 export function BottomMetadata({ ··· 119 <View style={[layout.flex.row, layout.flex.align.center, gap.all[4]]}> 120 <PlayerUI.Viewers /> 121 <ShareSheet /> 122 <View> 123 <Button 124 variant="outline"
··· 17 import FollowButton from "components/follow-button"; 18 import { ChevronLeft, ChevronRight } from "lucide-react-native"; 19 import { Image, Linking, Pressable, View } from "react-native"; 20 + import { KebabMenu } from "./desktop-ui/kebab"; 21 const { gap, px, py, colors } = zero; 22 23 export function BottomMetadata({ ··· 120 <View style={[layout.flex.row, layout.flex.align.center, gap.all[4]]}> 121 <PlayerUI.Viewers /> 122 <ShareSheet /> 123 + <KebabMenu /> 124 <View> 125 <Button 126 variant="outline"
+1
js/app/components/mobile/desktop-ui/index.ts
··· 1 export { BottomControlBar } from "./bottom-controls"; 2 export { LiveBubble } from "./live-bubble"; 3 export { MuteOverlay } from "./mute-overlay"; 4 export { TopControlBar } from "./top-controls";
··· 1 export { BottomControlBar } from "./bottom-controls"; 2 + export { KebabMenu } from "./kebab"; 3 export { LiveBubble } from "./live-bubble"; 4 export { MuteOverlay } from "./mute-overlay"; 5 export { TopControlBar } from "./top-controls";
+203
js/app/components/mobile/desktop-ui/kebab.tsx
···
··· 1 + import { 2 + AppBskyActorDefs, 3 + ComAtprotoModerationCreateReport, 4 + } from "@atproto/api"; 5 + import { useRootContext } from "@rn-primitives/dropdown-menu"; 6 + import { 7 + DropdownMenu, 8 + DropdownMenuGroup, 9 + DropdownMenuItem, 10 + DropdownMenuTrigger, 11 + ResponsiveDropdownMenuContent, 12 + Text, 13 + UpdateStreamTitleDialog, 14 + useCanModerate, 15 + useLivestream, 16 + useLivestreamInfo, 17 + useLivestreamStore, 18 + usePlayerStore, 19 + useTheme, 20 + useUpdateLivestreamRecord, 21 + } from "@streamplace/components"; 22 + import { EllipsisVertical } from "lucide-react-native"; 23 + import { useState } from "react"; 24 + import Animated, { 25 + Easing, 26 + useAnimatedStyle, 27 + withTiming, 28 + } from "react-native-reanimated"; 29 + import { LivestreamViewHydrated } from "streamplace"; 30 + 31 + type ReportSubject = 32 + | ComAtprotoModerationCreateReport.InputSchema["subject"] 33 + | null; 34 + 35 + interface KebabMenuProps { 36 + dropdownPortalContainer?: string; 37 + } 38 + 39 + export function KebabMenu({ dropdownPortalContainer }: KebabMenuProps) { 40 + const th = useTheme(); 41 + const [isOpen, setIsOpen] = useState(false); 42 + const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 43 + 44 + const livestreamFromStore = useLivestreamStore((x) => x.livestream); 45 + const livestream = useLivestream(); 46 + const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 47 + const setReportSubject = usePlayerStore((x) => x.setReportSubject); 48 + const { profile } = useLivestreamInfo(); 49 + 50 + // Get the streamer's DID from the profile 51 + const streamerDID = profile?.did; 52 + // Check moderation permissions for the current user on this streamer's channel 53 + const modPermissions = useCanModerate(streamerDID); 54 + const { updateLivestream, isLoading: isUpdateTitleLoading } = 55 + useUpdateLivestreamRecord(); 56 + 57 + const iconRotate = useAnimatedStyle(() => { 58 + return { 59 + transform: [ 60 + { 61 + rotateZ: withTiming(isOpen ? "5deg" : "0deg", { 62 + duration: 200, 63 + easing: Easing.out(Easing.ease), 64 + }), 65 + }, 66 + ], 67 + }; 68 + }); 69 + 70 + return ( 71 + <> 72 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 73 + <DropdownMenuTrigger> 74 + <Animated.View style={[iconRotate]}> 75 + <EllipsisVertical color={th.theme.colors.foreground} /> 76 + </Animated.View> 77 + </DropdownMenuTrigger> 78 + <ResponsiveDropdownMenuContent 79 + side="top" 80 + align="end" 81 + portalHost={dropdownPortalContainer} 82 + > 83 + {modPermissions.canManageLivestream && ( 84 + <DropdownMenuGroup title="Stream Settings"> 85 + <UpdateStreamTitleItem 86 + setShowUpdateTitleDialog={setShowUpdateTitleDialog} 87 + isUpdateTitleLoading={isUpdateTitleLoading} 88 + livestream={livestream} 89 + /> 90 + </DropdownMenuGroup> 91 + )} 92 + <DropdownMenuGroup title="Report"> 93 + <ReportStreamItem 94 + livestream={livestreamFromStore} 95 + setReportModalOpen={setReportModalOpen} 96 + setReportSubject={setReportSubject} 97 + /> 98 + <ReportUserItem 99 + profile={profile} 100 + setReportModalOpen={setReportModalOpen} 101 + setReportSubject={setReportSubject} 102 + /> 103 + </DropdownMenuGroup> 104 + </ResponsiveDropdownMenuContent> 105 + </DropdownMenu> 106 + 107 + {showUpdateTitleDialog && ( 108 + <UpdateStreamTitleDialog 109 + livestream={livestream} 110 + streamerDID={streamerDID} 111 + updateLivestream={updateLivestream} 112 + isLoading={isUpdateTitleLoading} 113 + onClose={() => setShowUpdateTitleDialog(false)} 114 + /> 115 + )} 116 + </> 117 + ); 118 + } 119 + 120 + function ReportStreamItem({ 121 + livestream, 122 + setReportModalOpen, 123 + setReportSubject, 124 + }: { 125 + livestream: LivestreamViewHydrated | null; 126 + setReportModalOpen: (open: boolean) => void; 127 + setReportSubject: (subject: ReportSubject) => void; 128 + }) { 129 + const { onOpenChange } = useRootContext(); 130 + 131 + return ( 132 + <DropdownMenuItem 133 + onPress={() => { 134 + if (!livestream) return; 135 + onOpenChange?.(false); 136 + setReportModalOpen(true); 137 + setReportSubject({ 138 + $type: "com.atproto.repo.strongRef", 139 + uri: livestream.uri, 140 + cid: livestream.cid, 141 + }); 142 + }} 143 + disabled={!livestream} 144 + > 145 + <Text>Report Livestream...</Text> 146 + </DropdownMenuItem> 147 + ); 148 + } 149 + 150 + function ReportUserItem({ 151 + profile, 152 + setReportModalOpen, 153 + setReportSubject, 154 + }: { 155 + profile: AppBskyActorDefs.ProfileViewBasic | null; 156 + setReportModalOpen: (open: boolean) => void; 157 + setReportSubject: (subject: ReportSubject) => void; 158 + }) { 159 + const { onOpenChange } = useRootContext(); 160 + 161 + return ( 162 + <DropdownMenuItem 163 + onPress={() => { 164 + if (!profile?.did) return; 165 + onOpenChange?.(false); 166 + setReportModalOpen(true); 167 + setReportSubject({ 168 + $type: "com.atproto.admin.defs#repoRef", 169 + did: profile.did, 170 + }); 171 + }} 172 + disabled={!profile?.did} 173 + > 174 + <Text>Report User...</Text> 175 + </DropdownMenuItem> 176 + ); 177 + } 178 + 179 + function UpdateStreamTitleItem({ 180 + setShowUpdateTitleDialog, 181 + isUpdateTitleLoading, 182 + livestream, 183 + }: { 184 + setShowUpdateTitleDialog: (show: boolean) => void; 185 + isUpdateTitleLoading: boolean; 186 + livestream: any; 187 + }) { 188 + const { onOpenChange } = useRootContext(); 189 + 190 + return ( 191 + <DropdownMenuItem 192 + onPress={() => { 193 + onOpenChange?.(false); 194 + setShowUpdateTitleDialog(true); 195 + }} 196 + disabled={isUpdateTitleLoading || !livestream} 197 + > 198 + <Text> 199 + {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 200 + </Text> 201 + </DropdownMenuItem> 202 + ); 203 + }
+1
js/app/components/mobile/desktop-ui.tsx
··· 254 setTitle={setTitle} 255 ingestStarting={ingestStarting} 256 toggleGoLive={toggleGoLive} 257 /> 258 )} 259
··· 254 setTitle={setTitle} 255 ingestStarting={ingestStarting} 256 toggleGoLive={toggleGoLive} 257 + isLive={isActivelyLive} 258 /> 259 )} 260
+16 -3
js/app/components/mobile/ui.tsx
··· 72 ingestStarting, 73 setIngestStarting, 74 toggleGoLive, 75 } = useLivestreamInfo(); 76 const { width, height } = usePlayerDimensions(); 77 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 102 103 const isSelfAndNotLive = ingest === "new"; 104 const isLive = ingest !== null && ingest !== "new"; 105 106 const FADE_OUT_DELAY = 4000; 107 const fadeOpacity = useSharedValue(1); ··· 222 <View 223 style={[ 224 layout.position.absolute, 225 - position.top[28], 226 position.left[0], 227 position.right[0], 228 layout.flex.column, ··· 230 ]} 231 > 232 <PlayerUI.MetricsPanel 233 - showMetrics={isLive || isSelfAndNotLive} 234 /> 235 </View> 236 )} ··· 241 setTitle={setTitle} 242 ingestStarting={ingestStarting} 243 toggleGoLive={toggleGoLive} 244 /> 245 )} 246 ··· 468 <Pressable onPress={doSetIngestCamera}> 469 <SwitchCamera color={theme.colors.foreground} size={20} /> 470 </Pressable> 471 </> 472 )} 473 {Platform.OS === "web" ? ( ··· 515 )} 516 </Pressable> 517 )} 518 - <PlayerUI.ContextMenu /> 519 </View> 520 )} 521 {shouldShowChatSidePanel && setShowChat && (
··· 72 ingestStarting, 73 setIngestStarting, 74 toggleGoLive, 75 + toggleStopStream, 76 } = useLivestreamInfo(); 77 const { width, height } = usePlayerDimensions(); 78 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 103 104 const isSelfAndNotLive = ingest === "new"; 105 const isLive = ingest !== null && ingest !== "new"; 106 + 107 + useEffect(() => { 108 + if (isLive && ingestStarting) { 109 + setIngestStarting(false); 110 + } 111 + }, [isLive, ingestStarting, setIngestStarting]); 112 113 const FADE_OUT_DELAY = 4000; 114 const fadeOpacity = useSharedValue(1); ··· 229 <View 230 style={[ 231 layout.position.absolute, 232 + position.top[32], 233 position.left[0], 234 position.right[0], 235 layout.flex.column, ··· 237 ]} 238 > 239 <PlayerUI.MetricsPanel 240 + showMetrics={shouldShowFloatingMetrics} 241 /> 242 </View> 243 )} ··· 248 setTitle={setTitle} 249 ingestStarting={ingestStarting} 250 toggleGoLive={toggleGoLive} 251 + isLive={isLive} 252 /> 253 )} 254 ··· 476 <Pressable onPress={doSetIngestCamera}> 477 <SwitchCamera color={theme.colors.foreground} size={20} /> 478 </Pressable> 479 + {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} 480 </> 481 )} 482 {Platform.OS === "web" ? ( ··· 524 )} 525 </Pressable> 526 )} 527 + {ingest === null ? ( 528 + <PlayerUI.ContextMenu /> 529 + ) : ( 530 + <PlayerUI.StreamContextMenu /> 531 + )} 532 </View> 533 )} 534 {shouldShowChatSidePanel && setShowChat && (
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 import { storage } from "@streamplace/components"; 3 import { useURL } from "expo-linking"; 4 import { useEffect, useState } from "react"; 5 import { useStore } from "store"; 6 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 import { navigateToRoute } from "utils/navigation"; ··· 23 loadOAuthClient(); 24 25 // load return route from storage on mount 26 storage.getItem("returnRoute").then((stored) => { 27 if (stored) { 28 try { ··· 82 if ( 83 lastAuthStatus !== "loggedIn" && 84 authStatus === "loggedIn" && 85 - returnRoute 86 ) { 87 console.log( 88 "Login successful, navigating back to returnRoute:",
··· 2 import { storage } from "@streamplace/components"; 3 import { useURL } from "expo-linking"; 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 6 import { useStore } from "store"; 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 8 import { navigateToRoute } from "utils/navigation"; ··· 24 loadOAuthClient(); 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 30 storage.getItem("returnRoute").then((stored) => { 31 if (stored) { 32 try { ··· 86 if ( 87 lastAuthStatus !== "loggedIn" && 88 authStatus === "loggedIn" && 89 + returnRoute && 90 + Platform.OS === "web" 91 ) { 92 console.log( 93 "Login successful, navigating back to returnRoute:",
+8 -3
js/app/hooks/useBlueskyNotifications.tsx
··· 1 import { useToast } from "@streamplace/components"; 2 import { CircleX } from "lucide-react-native"; 3 import { useEffect } from "react"; 4 import { useStore } from "../store"; 5 6 function titleCase(str: string) { ··· 18 let toast = useToast(); 19 const notification = useStore((state) => state.notification); 20 const clearNotification = useStore((state) => state.clearNotification); 21 22 useEffect(() => { 23 if (notification) { ··· 41 { 42 duration: 100, 43 variant: notification.type, 44 - actionLabel: "Copy message", 45 iconLeft: CircleX, 46 onAction: () => { 47 navigator.clipboard.writeText( ··· 59 notification.message, 60 { 61 variant: notification.type, 62 - actionLabel: "Copy message", 63 onAction: () => { 64 navigator.clipboard.writeText(notification.message); 65 }, ··· 74 notification.message, 75 { 76 variant: notification.type, 77 - actionLabel: "Copy message", 78 onAction: () => { 79 navigator.clipboard.writeText(notification.message); 80 },
··· 1 import { useToast } from "@streamplace/components"; 2 import { CircleX } from "lucide-react-native"; 3 import { useEffect } from "react"; 4 + import { Platform } from "react-native"; 5 + import clearQueryParams from "utils/clear-query-params"; 6 import { useStore } from "../store"; 7 8 function titleCase(str: string) { ··· 20 let toast = useToast(); 21 const notification = useStore((state) => state.notification); 22 const clearNotification = useStore((state) => state.clearNotification); 23 + 24 + // we've already saved the notif to the store 25 + clearQueryParams(["error", "error_description"]); 26 27 useEffect(() => { 28 if (notification) { ··· 46 { 47 duration: 100, 48 variant: notification.type, 49 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 50 iconLeft: CircleX, 51 onAction: () => { 52 navigator.clipboard.writeText( ··· 64 notification.message, 65 { 66 variant: notification.type, 67 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 68 onAction: () => { 69 navigator.clipboard.writeText(notification.message); 70 }, ··· 79 notification.message, 80 { 81 variant: notification.type, 82 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 83 onAction: () => { 84 navigator.clipboard.writeText(notification.message); 85 },
+1 -1
js/app/package.json
··· 1 { 2 "name": "@streamplace/app", 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.9.3", 5 "runtimeVersion": "0.7.2", 6 "scripts": { 7 "start": "npx expo start -c --port 38081",
··· 1 { 2 "name": "@streamplace/app", 3 "main": "./src/entrypoint.tsx", 4 + "version": "0.9.9", 5 "runtimeVersion": "0.7.2", 6 "scripts": { 7 "start": "npx expo start -c --port 38081",
+21 -7
js/app/src/router.tsx
··· 81 import HomeScreen from "./screens/home"; 82 83 import { useUrl } from "@streamplace/components"; 84 import { BrandingAdmin } from "components/settings/branding-admin"; 85 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 86 import MultistreamManager from "components/settings/multistream-manager"; ··· 297 const AvatarButton = () => { 298 const userProfile = useUserProfile(); 299 const openLoginModal = useStore((state) => state.openLoginModal); 300 const loginAction = useStore((state) => state.login); 301 const openLoginLink = useStore((state) => state.openLoginLink); 302 const { theme } = useTheme(); ··· 332 ); 333 } 334 335 - const handleSignup = () => { 336 - // TODO: remove requirement for oauth-protected-resource in oatproxy 337 - loginAction("https://bsky.social", openLoginLink); 338 - }; 339 - 340 if (isCompact) { 341 return ( 342 <Button ··· 369 <Text style={{ color: theme.colors.text }}>Log In</Text> 370 </Button> 371 <Button 372 - onPress={handleSignup} 373 variant="primary" 374 width="min" 375 style={[zero.r.full]} ··· 477 const pollMySegments = useStore((state) => state.pollMySegments); 478 const showLoginModal = useStore((state) => state.showLoginModal); 479 const closeLoginModal = useStore((state) => state.closeLoginModal); 480 const [livePopup, setLivePopup] = useState(false); 481 const siteTitle = useSiteTitle(); 482 const defaultStreamer = useDefaultStreamer(); 483 ··· 784 }} 785 /> 786 </Drawer.Navigator> 787 - <LoginModal visible={showLoginModal} onClose={closeLoginModal} /> 788 </> 789 ); 790 }
··· 81 import HomeScreen from "./screens/home"; 82 83 import { useUrl } from "@streamplace/components"; 84 + import PdsHostSelectorModal from "components/login/pds-host-selector-modal"; 85 import { BrandingAdmin } from "components/settings/branding-admin"; 86 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 87 import MultistreamManager from "components/settings/multistream-manager"; ··· 298 const AvatarButton = () => { 299 const userProfile = useUserProfile(); 300 const openLoginModal = useStore((state) => state.openLoginModal); 301 + const openPDSModal = useStore((state) => state.openPdsModal); 302 const loginAction = useStore((state) => state.login); 303 const openLoginLink = useStore((state) => state.openLoginLink); 304 const { theme } = useTheme(); ··· 334 ); 335 } 336 337 if (isCompact) { 338 return ( 339 <Button ··· 366 <Text style={{ color: theme.colors.text }}>Log In</Text> 367 </Button> 368 <Button 369 + onPress={() => openPDSModal()} 370 variant="primary" 371 width="min" 372 style={[zero.r.full]} ··· 474 const pollMySegments = useStore((state) => state.pollMySegments); 475 const showLoginModal = useStore((state) => state.showLoginModal); 476 const closeLoginModal = useStore((state) => state.closeLoginModal); 477 + const showPdsModal = useStore((state) => state.showPdsModal); 478 + const openPdsModal = useStore((state) => state.openPdsModal); 479 + const closePdsModal = useStore((state) => state.closePdsModal); 480 const [livePopup, setLivePopup] = useState(false); 481 + const loginAction = useStore((state) => state.login); 482 + const openLoginLink = useStore((state) => state.openLoginLink); 483 const siteTitle = useSiteTitle(); 484 const defaultStreamer = useDefaultStreamer(); 485 ··· 786 }} 787 /> 788 </Drawer.Navigator> 789 + <LoginModal 790 + visible={showLoginModal} 791 + onClose={closeLoginModal} 792 + onOpenPdsModal={openPdsModal} 793 + /> 794 + <PdsHostSelectorModal 795 + open={showPdsModal} 796 + onOpenChange={closePdsModal} 797 + onSubmit={(pdsHost) => { 798 + closePdsModal(); 799 + loginAction(pdsHost, openLoginLink); 800 + }} 801 + /> 802 </> 803 ); 804 }
+14 -16
js/app/store/slices/blueskySlice.ts
··· 19 PlaceStreamServerSettings, 20 StreamplaceAgent, 21 } from "streamplace"; 22 import { privateKeyToAccount } from "viem/accounts"; 23 import { StateCreator } from "zustand"; 24 import createOAuthClient, { ··· 86 showLoginModal: boolean; 87 openLoginModal: (returnRoute?: { name: string; params?: any }) => void; 88 closeLoginModal: () => void; 89 golivePost: ( 90 text: string, 91 now: Date, ··· 114 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 115 } 116 117 - const clearQueryParams = () => { 118 - if (Platform.OS !== "web") { 119 - return; 120 - } 121 - const u = new URL(document.location.href); 122 - const params = new URLSearchParams(u.search); 123 - if (u.search === "") { 124 - return; 125 - } 126 - params.delete("iss"); 127 - params.delete("state"); 128 - params.delete("code"); 129 - u.search = params.toString(); 130 - window.history.replaceState(null, "", u.toString()); 131 - }; 132 - 133 const uploadThumbnail = async ( 134 handle: string, 135 u: URL, ··· 210 serverSettings: null, 211 returnRoute: null, 212 showLoginModal: false, 213 notification: null, 214 215 clearNotification: () => { 216 set({ notification: null }); 217 }, 218 ··· 237 closeLoginModal: () => { 238 console.log("closeLoginModal"); 239 set({ showLoginModal: false }); 240 }, 241 242 loadOAuthClient: async () => {
··· 19 PlaceStreamServerSettings, 20 StreamplaceAgent, 21 } from "streamplace"; 22 + import clearQueryParams from "utils/clear-query-params"; 23 import { privateKeyToAccount } from "viem/accounts"; 24 import { StateCreator } from "zustand"; 25 import createOAuthClient, { ··· 87 showLoginModal: boolean; 88 openLoginModal: (returnRoute?: { name: string; params?: any }) => void; 89 closeLoginModal: () => void; 90 + showPdsModal: boolean; 91 + openPdsModal: () => void; 92 + closePdsModal: () => void; 93 golivePost: ( 94 text: string, 95 now: Date, ··· 118 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 119 } 120 121 const uploadThumbnail = async ( 122 handle: string, 123 u: URL, ··· 198 serverSettings: null, 199 returnRoute: null, 200 showLoginModal: false, 201 + showPdsModal: false, 202 notification: null, 203 204 clearNotification: () => { 205 + clearQueryParams(); 206 set({ notification: null }); 207 }, 208 ··· 227 closeLoginModal: () => { 228 console.log("closeLoginModal"); 229 set({ showLoginModal: false }); 230 + }, 231 + 232 + openPdsModal: () => { 233 + set({ showPdsModal: true }); 234 + }, 235 + 236 + closePdsModal: () => { 237 + set({ showPdsModal: false }); 238 }, 239 240 loadOAuthClient: async () => {
+11 -15
js/app/store/slices/contentMetadataSlice.ts
··· 1 import { AppStore } from "store"; 2 import { StateCreator } from "zustand"; 3 import { BlueskySlice } from "./blueskySlice"; ··· 201 }); 202 203 try { 204 - let targetPDS = null; 205 try { 206 - const didResponse = await fetch(`https://plc.directory/${targetDid}`); 207 - if (didResponse.ok) { 208 - const didDoc = await didResponse.json(); 209 - const pdsService = didDoc.service?.find( 210 - (s: any) => s.id === "#atproto_pds", 211 - ); 212 - if (pdsService) { 213 - targetPDS = pdsService.serviceEndpoint; 214 - console.log( 215 - `[getContentMetadata] Resolved PDS for ${targetDid}:`, 216 - targetPDS, 217 - ); 218 - } 219 - } 220 } catch (pdsResolveError) { 221 console.log( 222 `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`,
··· 1 + import { 2 + getPDSServiceEndpoint, 3 + resolveDIDDocument, 4 + } from "@streamplace/components"; 5 import { AppStore } from "store"; 6 import { StateCreator } from "zustand"; 7 import { BlueskySlice } from "./blueskySlice"; ··· 205 }); 206 207 try { 208 + let targetPDS: string | null = null; 209 try { 210 + const didDoc = await resolveDIDDocument(targetDid); 211 + targetPDS = getPDSServiceEndpoint(didDoc); 212 + console.log( 213 + `[getContentMetadata] Resolved PDS for ${targetDid}:`, 214 + targetPDS, 215 + ); 216 } catch (pdsResolveError) { 217 console.log( 218 `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`,
+15
js/app/utils/clear-query-params.ts
···
··· 1 + import { Platform } from "react-native"; 2 + 3 + export default function clearQueryParams(par = ["iss", "state", "code"]) { 4 + if (Platform.OS !== "web") { 5 + return; 6 + } 7 + const u = new URL(document.location.href); 8 + const params = new URLSearchParams(u.search); 9 + if (u.search === "") { 10 + return; 11 + } 12 + par.forEach((p) => params.delete(p)); 13 + u.search = params.toString(); 14 + window.history.replaceState(null, "", u.toString()); 15 + }
+3 -6
js/atproto-oauth-client-react-native/README.md
··· 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 instead need to set up TLS. 89 90 - [react-native-quick-crypto]: 91 - https://github.com/margelo/react-native-quick-crypto 92 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 93 - [README]: 94 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 95 - [example]: 96 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
··· 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 instead need to set up TLS. 89 90 + [react-native-quick-crypto]: https://github.com/margelo/react-native-quick-crypto 91 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 92 + [README]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 93 + [example]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
+1 -1
js/atproto-oauth-client-react-native/package.json
··· 1 { 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 - "version": "0.9.0", 4 "license": "MIT", 5 "description": "ATProto OAuth client for React Native", 6 "keywords": [
··· 1 { 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 + "version": "0.9.9", 4 "license": "MIT", 5 "description": "ATProto OAuth client for React Native", 6 "keywords": [
+8
js/components/locales/en-US/chat.ftl
···
··· 1 + censored-text-hide = Hide Text 2 + censored-text-reveal = Reveal Text 3 + censored-text-blocked-with-reasons = This text was blocked because of these reasons: { $reasons } 4 + censored-text-blocked-unknown = This text was blocked for an unknown reason 5 + 6 + category-discriminatory = Discriminatory content 7 + category-sexually-explicit = Sexually explicit content 8 + category-profanity = Profanity
+13 -1
js/components/locales/en-US/common.ftl
··· 51 [streamer] Looks like <1>@{ $handle } is offline</1>, but they recommend checking out: 52 *[default] Looks like <1>@{ $handle } is offline</1>, but we recommend checking out: 53 } 54 - user-offline-no-recommendations = 55 Looks like <1>@{ $handle } is offline</1> right now. 56 Check back later. 57 streaming-title = streaming { $title } ··· 60 [1] 1 viewer 61 *[other] { $count } viewers 62 }
··· 51 [streamer] Looks like <1>@{ $handle } is offline</1>, but they recommend checking out: 52 *[default] Looks like <1>@{ $handle } is offline</1>, but we recommend checking out: 53 } 54 + user-offline-no-recommendations = 55 Looks like <1>@{ $handle } is offline</1> right now. 56 Check back later. 57 streaming-title = streaming { $title } ··· 60 [1] 1 viewer 61 *[other] { $count } viewers 62 } 63 + 64 + ## PDS Host Selector 65 + pds-selector-title = New to the Atmosphere? 66 + pds-selector-description = You'll need to select a PDS (Personal Data Server) to access apps on the Atmosphere, such as Bluesky, Tangled, and Spark. 67 + pds-selector-custom-label = Another PDS 68 + pds-selector-custom-description = Enter your own PDS host URL 69 + pds-selector-custom-url-label = Custom PDS URL 70 + pds-selector-custom-url-placeholder = https://pds.example.com 71 + pds-selector-learn-more = Learn more about self-hosting 72 + pds-selector-info = Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose. 73 + pds-selector-read-policies = Read { $label }'s <tosLink>Terms of Service</tosLink> and <privacyLink>Privacy Policy</privacyLink> before continuing. 74 + pds-selector-handle-policy-checkbox = I have read and agree to the <policyLink>handle policy</policyLink>
+2 -1
js/components/locales/en-US/settings.ftl
··· 121 active = Active 122 123 ## Multistreaming 124 - multistreaming = Multistreaming 125 multistream-targets = Multistream Targets 126 multistream-description = Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube. 127 create-multistream-target = Create Multistream Target ··· 168 no-languages-found = No languages found 169 170 ## Branding Administration 171 branding-admin = Branding Administration 172 branding-admin-description = Customize your Streamplace instance. Note that settings may take a few hours to propagate. 173 branding-login-required = Please log in to manage branding
··· 121 active = Active 122 123 ## Multistreaming 124 + multistream = Multistreaming 125 multistream-targets = Multistream Targets 126 multistream-description = Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube. 127 create-multistream-target = Create Multistream Target ··· 168 no-languages-found = No languages found 169 170 ## Branding Administration 171 + branding = Branding 172 branding-admin = Branding Administration 173 branding-admin-description = Customize your Streamplace instance. Note that settings may take a few hours to propagate. 174 branding-login-required = Please log in to manage branding
+8
js/components/locales/es-ES/chat.ftl
···
··· 1 + censored-text-hide = Ocultar texto 2 + censored-text-reveal = Revelar texto 3 + censored-text-blocked-with-reasons = Este texto fue bloqueado por estas razones: { $reasons } 4 + censored-text-blocked-unknown = Este texto fue bloqueado por un motivo desconocido 5 + 6 + category-discriminatory = Contenido discriminatorio 7 + category-sexually-explicit = Contenido sexualmente explícito 8 + category-profanity = Blasfemia
+8
js/components/locales/fr-FR/chat.ftl
···
··· 1 + censored-text-hide = Masquer le texte 2 + censored-text-reveal = Révéler le texte 3 + censored-text-blocked-with-reasons = Ce texte a été bloqué pour ces raisons : { $reasons } 4 + censored-text-blocked-unknown = Ce texte a été bloqué pour une raison inconnue 5 + 6 + category-discriminatory = Contenu discriminatoire 7 + category-sexually-explicit = Contenu sexuellement explicite 8 + category-profanity = Blasphème
+8
js/components/locales/pt-BR/chat.ftl
···
··· 1 + censored-text-hide = Ocultar texto 2 + censored-text-reveal = Revelar texto 3 + censored-text-blocked-with-reasons = Este texto foi bloqueado por estes motivos: { $reasons } 4 + censored-text-blocked-unknown = Este texto foi bloqueado por um motivo desconhecido 5 + 6 + category-discriminatory = Conteúdo discriminatório 7 + category-sexually-explicit = Conteúdo sexualmente explícito 8 + category-profanity = Profanidade
+8
js/components/locales/zh-Hant/chat.ftl
···
··· 1 + censored-text-hide = 隱藏文字 2 + censored-text-reveal = 顯示文字 3 + censored-text-blocked-with-reasons = 此文字因以下原因被封鎖:{ $reasons } 4 + censored-text-blocked-unknown = 此文字因未知原因被封鎖 5 + 6 + category-discriminatory = 歧視性內容 7 + category-sexually-explicit = 色情內容 8 + category-profanity = 粗俗語言
+2 -1
js/components/package.json
··· 1 { 2 "name": "@streamplace/components", 3 - "version": "0.9.1", 4 "description": "Streamplace React (Native) Components", 5 "main": "dist/index.js", 6 "types": "src/index.tsx", ··· 42 "expo-sensors": "^15.0.7", 43 "expo-sqlite": "~15.2.12", 44 "expo-video": "^2.0.0", 45 "hls.js": "^1.5.17", 46 "i18next": "^25.4.2", 47 "i18next-browser-languagedetector": "^8.2.0",
··· 1 { 2 "name": "@streamplace/components", 3 + "version": "0.9.9", 4 "description": "Streamplace React (Native) Components", 5 "main": "dist/index.js", 6 "types": "src/index.tsx", ··· 42 "expo-sensors": "^15.0.7", 43 "expo-sqlite": "~15.2.12", 44 "expo-video": "^2.0.0", 45 + "graphemer": "^1.4.0", 46 "hls.js": "^1.5.17", 47 "i18next": "^25.4.2", 48 "i18next-browser-languagedetector": "^8.2.0",
+88
js/components/src/components/chat/censored-text.tsx
···
··· 1 + import { TriggerRef } from "@rn-primitives/dropdown-menu"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { useChatFilters } from "../../streamplace-store"; 5 + import { 6 + DropdownMenu, 7 + DropdownMenuGroup, 8 + DropdownMenuInfo, 9 + DropdownMenuItem, 10 + DropdownMenuTrigger, 11 + ResponsiveDropdownMenuContent, 12 + } from "../ui/dropdown"; 13 + import { Text } from "../ui/text"; 14 + import { ChatFilterCategory } from "./chat-settings"; 15 + 16 + function getCategoryKey(category: string): string { 17 + const categoryMap: Record<string, string> = { 18 + "place.stream.richtext.defs#discriminatory": "category-discriminatory", 19 + "place.stream.richtext.defs#sexually_explicit": 20 + "category-sexually-explicit", 21 + "place.stream.richtext.defs#profanity": "category-profanity", 22 + }; 23 + return categoryMap[category] || category; 24 + } 25 + 26 + export function CensoredText({ 27 + text, 28 + reasoning, 29 + }: { 30 + text: string; 31 + reasoning?: string[]; 32 + }) { 33 + const filters = useChatFilters(); 34 + const { t } = useTranslation("chat"); 35 + const hasFilterMatch = reasoning?.some((r) => 36 + filters.has(r as ChatFilterCategory), 37 + ); 38 + const [revealed, setRevealed] = useState(!hasFilterMatch); 39 + const dropdownRef = useRef<TriggerRef>(null); 40 + const handleOpenDropdown = () => { 41 + dropdownRef.current?.open(); 42 + }; 43 + 44 + const translatedReasons = reasoning?.map((r) => t(getCategoryKey(r))); 45 + 46 + // update when filters change 47 + useEffect(() => { 48 + const match = reasoning?.some((r) => filters.has(r as ChatFilterCategory)); 49 + if (match) { 50 + setRevealed(false); 51 + } else { 52 + setRevealed(true); 53 + } 54 + }, [filters]); 55 + 56 + return ( 57 + <> 58 + <Text 59 + color={revealed ? "default" : "primary"} 60 + style={{ display: "inline" as any }} 61 + onPress={handleOpenDropdown} 62 + > 63 + {revealed ? text : text.replace(/./g, "*")} 64 + </Text> 65 + <DropdownMenu> 66 + <DropdownMenuTrigger ref={dropdownRef}></DropdownMenuTrigger> 67 + <ResponsiveDropdownMenuContent> 68 + <DropdownMenuGroup> 69 + <DropdownMenuItem onPress={() => setRevealed(!revealed)}> 70 + <Text> 71 + {revealed ? t("censored-text-hide") : t("censored-text-reveal")} 72 + </Text> 73 + </DropdownMenuItem> 74 + </DropdownMenuGroup> 75 + <DropdownMenuInfo 76 + description={ 77 + translatedReasons 78 + ? t("censored-text-blocked-with-reasons", { 79 + reasons: translatedReasons.join(", "), 80 + }) 81 + : t("censored-text-blocked-unknown") 82 + } 83 + /> 84 + </ResponsiveDropdownMenuContent> 85 + </DropdownMenu> 86 + </> 87 + ); 88 + }
+44 -3
js/components/src/components/chat/chat-box.tsx
··· 1 import Picker from "@emoji-mart/react"; 2 import { AtSignIcon, ExternalLink, X } from "lucide-react-native"; 3 import { env } from "process"; 4 import { useEffect, useMemo, useRef, useState } from "react"; 5 import { Platform, Pressable, TextInput } from "react-native"; 6 import { ChatMessageViewHydrated } from "streamplace"; 7 - import { Button, Loader, Text, useTheme, View } from "../../"; 8 import { handleSlashCommand } from "../../lib/slash-commands"; 9 import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 10 import { StreamNotifications } from "../../lib/stream-notifications"; ··· 30 useReplyToMessage, 31 useSetReplyToMessage, 32 } from "../../livestream-store"; 33 - import { useDID, usePDSAgent } from "../../streamplace-store"; 34 import { Textarea } from "../ui/textarea"; 35 import { RenderChatMessage } from "./chat-message"; 36 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 40 // @ts-ignore we can iterate through this just fine it seems 41 ..."😀🥸😍😘😁🥸😆🥸😜🥸😂😅🥸🙂🤫😱🥸🤣😗😄🥸😎🤓😲😯😰🥸😥🥸😣🥸😞😓🥸😩😩🥸😤🥱", 42 ]; 43 44 export function ChatBox({ 45 isPopout, ··· 65 new Map(), 66 ); 67 const [filteredEmojis, setFilteredEmojis] = useState<any[]>([]); 68 69 let linfo = useLivestream(); 70 71 const { theme, zero: zt } = useTheme(); 72 ··· 255 256 const submit = async () => { 257 if (!message.trim()) return; 258 259 const messageText = message; 260 setMessage(""); ··· 457 } 458 } 459 }} 460 - style={[chatBoxStyle]} 461 // "submit" won't blur on enter 462 submitBehavior="submit" 463 placeholder="Type a message..." ··· 473 {submitting ? <Loader /> : "Send"} 474 </Button> 475 </View> 476 </View> 477 {showSuggestions && ( 478 <MentionSuggestions ··· 562 > 563 <ExternalLink color={theme.colors.primaryForeground} size={16} /> 564 </Button> 565 )} 566 </View> 567 )}
··· 1 import Picker from "@emoji-mart/react"; 2 + import Graphemer from "graphemer"; 3 import { AtSignIcon, ExternalLink, X } from "lucide-react-native"; 4 import { env } from "process"; 5 import { useEffect, useMemo, useRef, useState } from "react"; 6 import { Platform, Pressable, TextInput } from "react-native"; 7 import { ChatMessageViewHydrated } from "streamplace"; 8 + import { 9 + Button, 10 + ChatSettings, 11 + Loader, 12 + Text, 13 + toast, 14 + useTheme, 15 + View, 16 + } from "../../"; 17 import { handleSlashCommand } from "../../lib/slash-commands"; 18 import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 19 import { StreamNotifications } from "../../lib/stream-notifications"; ··· 39 useReplyToMessage, 40 useSetReplyToMessage, 41 } from "../../livestream-store"; 42 + import { 43 + useDID, 44 + usePDSAgent, 45 + useSetChatFilters, 46 + } from "../../streamplace-store"; 47 import { Textarea } from "../ui/textarea"; 48 import { RenderChatMessage } from "./chat-message"; 49 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 53 // @ts-ignore we can iterate through this just fine it seems 54 ..."😀🥸😍😘😁🥸😆🥸😜🥸😂😅🥸🙂🤫😱🥸🤣😗😄🥸😎🤓😲😯😰🥸😥🥸😣🥸😞😓🥸😩😩🥸😤🥱", 55 ]; 56 + 57 + const graphemer = new Graphemer(); 58 59 export function ChatBox({ 60 isPopout, ··· 80 new Map(), 81 ); 82 const [filteredEmojis, setFilteredEmojis] = useState<any[]>([]); 83 + const isOverLimit = graphemer.countGraphemes(message) > 300; 84 85 let linfo = useLivestream(); 86 + const setChatFilters = useSetChatFilters(); 87 88 const { theme, zero: zt } = useTheme(); 89 ··· 272 273 const submit = async () => { 274 if (!message.trim()) return; 275 + if (graphemer.countGraphemes(message) > 300) { 276 + toast.show( 277 + "Message too long", 278 + "Please limit your message to 300 characters.", 279 + { 280 + variant: "error", 281 + duration: 3, 282 + }, 283 + ); 284 + return; 285 + } 286 287 const messageText = message; 288 setMessage(""); ··· 485 } 486 } 487 }} 488 + style={[ 489 + chatBoxStyle, 490 + isOverLimit && { 491 + borderColor: "#ef4444", 492 + borderWidth: 2, 493 + outline: "none", 494 + }, 495 + ]} 496 // "submit" won't blur on enter 497 submitBehavior="submit" 498 placeholder="Type a message..." ··· 508 {submitting ? <Loader /> : "Send"} 509 </Button> 510 </View> 511 + {Platform.OS !== "web" && ( 512 + <ChatSettings onFiltersChange={setChatFilters} /> 513 + )} 514 </View> 515 {showSuggestions && ( 516 <MentionSuggestions ··· 600 > 601 <ExternalLink color={theme.colors.primaryForeground} size={16} /> 602 </Button> 603 + )} 604 + {Platform.OS === "web" && ( 605 + <ChatSettings onFiltersChange={setChatFilters} /> 606 )} 607 </View> 608 )}
+15 -1
js/components/src/components/chat/chat-message.tsx
··· 25 26 import { useLivestreamStore } from "../../livestream-store"; 27 import { Text } from "../ui/text"; 28 29 const getRgbColor = (color?: { red: number; green: number; blue: number }) => 30 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : colors.gray[500]; ··· 67 {obj.text} 68 </Text> 69 ); 70 } else { 71 // render as normal text if we don't recognize the facet type 72 return <Text key={`unknown-facet-${index}`}>{obj.text}</Text>; ··· 91 92 return segs.map((seg, i) => segmentedObject(seg, i, userCache)); 93 }; 94 export const RenderChatMessage = memo( 95 function RenderChatMessage({ 96 item, ··· 147 fontStyle: "italic", 148 }} 149 > 150 - {replyTo.record.text} 151 </Text> 152 </Text> 153 </View>
··· 25 26 import { useLivestreamStore } from "../../livestream-store"; 27 import { Text } from "../ui/text"; 28 + import { CensoredText } from "./censored-text"; 29 30 const getRgbColor = (color?: { red: number; green: number; blue: number }) => 31 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : colors.gray[500]; ··· 68 {obj.text} 69 </Text> 70 ); 71 + } else if (ftr.$type === "place.stream.richtext.defs#censor") { 72 + let censorFtr = ftr as any; 73 + return ( 74 + <CensoredText 75 + key={`censor-facet-${index}`} 76 + text={obj.text} 77 + reasoning={censorFtr.categories} 78 + /> 79 + ); 80 } else { 81 // render as normal text if we don't recognize the facet type 82 return <Text key={`unknown-facet-${index}`}>{obj.text}</Text>; ··· 101 102 return segs.map((seg, i) => segmentedObject(seg, i, userCache)); 103 }; 104 + 105 export const RenderChatMessage = memo( 106 function RenderChatMessage({ 107 item, ··· 158 fontStyle: "italic", 159 }} 160 > 161 + <RichTextMessage 162 + text={replyTo.record.text} 163 + facets={replyTo.record.facets || []} 164 + /> 165 </Text> 166 </Text> 167 </View>
+147
js/components/src/components/chat/chat-settings.tsx
···
··· 1 + import { EllipsisVertical } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import { Platform, Pressable, View } from "react-native"; 4 + import { Button, zero } from "../.."; 5 + import { 6 + ChatFilterCategory, 7 + useChatFilters, 8 + useSetChatFilters, 9 + } from "../../streamplace-store"; 10 + import { useTheme } from "../../ui"; 11 + import { 12 + DropdownMenu, 13 + DropdownMenuCheckboxItem, 14 + DropdownMenuGroup, 15 + DropdownMenuInfo, 16 + DropdownMenuItem, 17 + DropdownMenuSeparator, 18 + DropdownMenuSub, 19 + DropdownMenuSubContent, 20 + DropdownMenuSubTrigger, 21 + DropdownMenuTrigger, 22 + ResponsiveDropdownMenuContent, 23 + } from "../ui/dropdown"; 24 + import { Text } from "../ui/text"; 25 + 26 + export type { ChatFilterCategory }; 27 + 28 + interface ChatSettingsProps { 29 + onFiltersChange?: (filters: Set<ChatFilterCategory>) => void; 30 + } 31 + 32 + const CATEGORY_LABELS: Record<ChatFilterCategory, string> = { 33 + "place.stream.richtext.defs#discriminatory": "Discriminatory", 34 + "place.stream.richtext.defs#sexually_explicit": "Sexually Explicit", 35 + "place.stream.richtext.defs#profanity": "Profanity", 36 + }; 37 + 38 + const ALL_CATEGORIES: ChatFilterCategory[] = [ 39 + "place.stream.richtext.defs#discriminatory", 40 + "place.stream.richtext.defs#sexually_explicit", 41 + "place.stream.richtext.defs#profanity", 42 + ]; 43 + 44 + export function ChatSettings({ onFiltersChange }: ChatSettingsProps) { 45 + const { icons } = useTheme(); 46 + const storedFilters = useChatFilters(); 47 + const setStoredFilters = useSetChatFilters(); 48 + const [filters, setFilters] = 49 + useState<Set<ChatFilterCategory>>(storedFilters); 50 + 51 + const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 52 + 53 + // Sync local state with stored filters on mount and when stored filters change 54 + useEffect(() => { 55 + setFilters(storedFilters); 56 + }, [storedFilters]); 57 + 58 + const toggleFilter = (category: ChatFilterCategory) => { 59 + const newFilters = new Set(filters); 60 + if (newFilters.has(category)) { 61 + newFilters.delete(category); 62 + } else { 63 + newFilters.add(category); 64 + } 65 + setFilters(newFilters); 66 + setStoredFilters(newFilters); 67 + onFiltersChange?.(newFilters); 68 + }; 69 + 70 + const allFiltersEnabled = filters.size === ALL_CATEGORIES.length; 71 + 72 + const toggleAllFilters = () => { 73 + const newFilters = allFiltersEnabled 74 + ? new Set<ChatFilterCategory>() 75 + : new Set(ALL_CATEGORIES); 76 + setFilters(newFilters); 77 + setStoredFilters(newFilters); 78 + onFiltersChange?.(newFilters); 79 + }; 80 + 81 + return ( 82 + <DropdownMenu> 83 + <DropdownMenuTrigger> 84 + <Pressable> 85 + {({ pressed }) => ( 86 + <Button 87 + variant="ghost" 88 + aria-label="Popout Chat" 89 + style={{ borderRadius: 16, maxHeight: 44, aspectRatio: 0.5 }} 90 + > 91 + <EllipsisVertical size={20} color={icons.color.muted} /> 92 + </Button> 93 + )} 94 + </Pressable> 95 + </DropdownMenuTrigger> 96 + <ResponsiveDropdownMenuContent align="end"> 97 + <DropdownMenuGroup title="Chat Settings"> 98 + <DropdownMenuSub> 99 + <DropdownMenuSubTrigger subMenuTitle="Chat Filters"> 100 + <View 101 + style={[ 102 + zero.flex.values[1], 103 + isMobile ? zero.layout.flex.row : zero.layout.flex.column, 104 + zero.layout.flex.spaceBetween, 105 + zero.pr[4], 106 + ]} 107 + > 108 + <Text>Chat Filters</Text> 109 + </View> 110 + </DropdownMenuSubTrigger> 111 + <DropdownMenuSubContent> 112 + <DropdownMenuGroup title="Content Filters"> 113 + <DropdownMenuItem onPress={toggleAllFilters}> 114 + <Text> 115 + {allFiltersEnabled ? "Disable All" : "Enable All"} 116 + </Text> 117 + </DropdownMenuItem> 118 + </DropdownMenuGroup> 119 + <DropdownMenuGroup> 120 + {( 121 + Object.entries(CATEGORY_LABELS) as [ 122 + ChatFilterCategory, 123 + string, 124 + ][] 125 + ).map(([category, label], i) => ( 126 + <> 127 + <DropdownMenuCheckboxItem 128 + key={category} 129 + checked={filters.has(category)} 130 + onCheckedChange={() => toggleFilter(category)} 131 + > 132 + <Text>{label}</Text> 133 + </DropdownMenuCheckboxItem> 134 + {i < Object.entries(CATEGORY_LABELS).length - 1 && ( 135 + <DropdownMenuSeparator /> 136 + )} 137 + </> 138 + ))} 139 + </DropdownMenuGroup> 140 + <DropdownMenuInfo description="Hide messages containing content that may be inappropriate or offensive by category." /> 141 + </DropdownMenuSubContent> 142 + </DropdownMenuSub> 143 + </DropdownMenuGroup> 144 + </ResponsiveDropdownMenuContent> 145 + </DropdownMenu> 146 + ); 147 + }
+2 -2
js/components/src/components/chat/chat.tsx
··· 261 262 useEffect(() => { 263 buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 - buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 20, { 265 duration: 200, 266 }); 267 }, [isScrolledUp]); ··· 345 onPress={scrollToBottom} 346 style={[ 347 { 348 - pointerEvents: "auto", 349 backgroundColor: theme.colors.primary, 350 opacity: 0.9, 351 borderRadius: 20,
··· 261 262 useEffect(() => { 263 buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 + buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 50, { 265 duration: 200, 266 }); 267 }, [isScrolledUp]); ··· 345 onPress={scrollToBottom} 346 style={[ 347 { 348 + pointerEvents: isScrolledUp ? "auto" : "none", 349 backgroundColor: theme.colors.primary, 350 opacity: 0.9, 351 borderRadius: 20,
+2 -218
js/components/src/components/chat/mod-view.tsx
··· 5 import { 6 useCreateBlockRecord, 7 useCreateHideChatRecord, 8 - useUpdateLivestreamRecord, 9 } from "../../streamplace-store/block"; 10 import { 11 ModerationPermissions, ··· 17 import { ChatMessageViewHydrated } from "streamplace"; 18 import { 19 useDeleteChatMessage, 20 - useLivestream, 21 useLivestreamStore, 22 } from "../../livestream-store"; 23 import { useStreamplaceStore } from "../../streamplace-store"; 24 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; 25 import { 26 atoms, 27 - Button, 28 - DialogFooter, 29 DropdownMenu, 30 DropdownMenuGroup, 31 DropdownMenuItem, 32 DropdownMenuTrigger, 33 layout, 34 - ResponsiveDialog, 35 ResponsiveDropdownMenuContent, 36 Text, 37 - Textarea, 38 useToast, 39 View, 40 } from "../ui"; ··· 61 let [messageRemoved, setMessageRemoved] = useState(false); 62 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 63 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 64 - let { updateLivestream, isLoading: isUpdateTitleLoading } = 65 - useUpdateLivestreamRecord(); 66 - const livestream = useLivestream(); 67 - const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 68 69 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 70 const setReportSubject = usePlayerStore((x) => x.setReportSubject); ··· 95 } 96 }, [message]); 97 98 - // Early return AFTER all hooks have been called 99 - if (!agent?.did) { 100 - return <></>; 101 - } 102 - 103 - // Can show moderation actions if user can hide, ban, or manage livestream 104 - const canModerate = 105 - modPermissions.canHide || 106 - modPermissions.canBan || 107 - modPermissions.canManageLivestream; 108 - 109 // Check if any moderation actions are actually available for this message 110 // This must match the individual action checks inside the DropdownMenuGroup 111 const hasAvailableActions = !!( ··· 146 createHideChat={createHideChat} 147 createBlock={createBlock} 148 toast={toast} 149 - setShowUpdateTitleDialog={setShowUpdateTitleDialog} 150 - isUpdateTitleLoading={isUpdateTitleLoading} 151 - livestream={livestream} 152 setReportModalOpen={setReportModalOpen} 153 setReportSubject={setReportSubject} 154 deleteChatMessage={deleteChatMessage} ··· 156 )} 157 </ResponsiveDropdownMenuContent> 158 </DropdownMenu> 159 - 160 - {/* Update Stream Title Dialog - rendered outside dropdown */} 161 - {showUpdateTitleDialog && ( 162 - <UpdateStreamTitleDialog 163 - livestream={livestream} 164 - streamerDID={streamerDID} 165 - updateLivestream={updateLivestream} 166 - isLoading={isUpdateTitleLoading} 167 - onClose={() => setShowUpdateTitleDialog(false)} 168 - /> 169 - )} 170 </> 171 ); 172 }); ··· 184 createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 185 createBlock: (did: string, streamerDID?: string) => Promise<any>; 186 toast: ReturnType<typeof useToast>; 187 - setShowUpdateTitleDialog: (show: boolean) => void; 188 - isUpdateTitleLoading: boolean; 189 - livestream: any; 190 setReportModalOpen: (open: boolean) => void; 191 setReportSubject: (subject: any) => void; 192 deleteChatMessage: (uri: string) => Promise<any>; ··· 205 createHideChat, 206 createBlock, 207 toast, 208 - setShowUpdateTitleDialog, 209 - isUpdateTitleLoading, 210 - livestream, 211 setReportModalOpen, 212 setReportSubject, 213 deleteChatMessage, ··· 301 </DropdownMenuGroup> 302 )} 303 304 - {modPermissions.canManageLivestream && ( 305 - <DropdownMenuGroup key="stream-actions" title={`Stream actions`}> 306 - <DropdownMenuItem 307 - onPress={() => { 308 - setShowUpdateTitleDialog(true); 309 - }} 310 - disabled={isUpdateTitleLoading || !livestream} 311 - > 312 - <Text 313 - color={isUpdateTitleLoading || !livestream ? "muted" : "primary"} 314 - > 315 - {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 316 - </Text> 317 - </DropdownMenuItem> 318 - </DropdownMenuGroup> 319 - )} 320 - 321 <DropdownMenuGroup key="user-actions" title={`User actions`}> 322 <DropdownMenuItem 323 onPress={() => { ··· 328 > 329 <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 330 </DropdownMenuItem> 331 - {message.author.did === agent?.did && ( 332 <DeleteButton 333 message={message} 334 deleteChatMessage={deleteChatMessage} 335 onOpenChange={onOpenChange} 336 /> 337 )} 338 - {message.author.did !== agent?.did && ( 339 <ReportButton 340 message={message} 341 setReportModalOpen={setReportModalOpen} ··· 429 </DropdownMenuItem> 430 ); 431 } 432 - 433 - interface UpdateStreamTitleDialogProps { 434 - livestream: any; 435 - streamerDID?: string; 436 - updateLivestream: ( 437 - livestreamUri: string, 438 - title: string, 439 - streamerDID?: string, 440 - ) => Promise<any>; 441 - isLoading: boolean; 442 - onClose: () => void; 443 - } 444 - 445 - function UpdateStreamTitleDialog({ 446 - livestream, 447 - streamerDID, 448 - updateLivestream, 449 - isLoading, 450 - onClose, 451 - }: UpdateStreamTitleDialogProps) { 452 - const [title, setTitle] = useState(livestream?.record?.title || ""); 453 - const [error, setError] = useState<string | null>(null); 454 - const toast = useToast(); 455 - 456 - useEffect(() => { 457 - if (livestream?.record?.title) { 458 - setTitle(livestream.record.title); 459 - } 460 - }, [livestream?.record?.title]); 461 - 462 - const handleUpdate = async () => { 463 - setError(null); 464 - 465 - if (!title.trim()) { 466 - setError("Please enter a stream title"); 467 - return; 468 - } 469 - 470 - if (!livestream?.uri) { 471 - setError("No livestream found"); 472 - return; 473 - } 474 - 475 - try { 476 - await updateLivestream(livestream.uri, title.trim(), streamerDID); 477 - toast.show( 478 - "Stream title updated", 479 - "The stream title has been successfully updated.", 480 - { duration: 3 }, 481 - ); 482 - onClose(); 483 - } catch (err) { 484 - setError( 485 - err instanceof Error ? err.message : "Failed to update stream title", 486 - ); 487 - } 488 - }; 489 - 490 - return ( 491 - <ResponsiveDialog 492 - open={true} 493 - onOpenChange={(open) => { 494 - if (!open) { 495 - onClose(); 496 - setError(null); 497 - setTitle(livestream?.record?.title || ""); 498 - } 499 - }} 500 - title="Update Stream Title" 501 - description="Update the title of the livestream." 502 - size="md" 503 - dismissible={false} 504 - > 505 - <View style={[{ padding: 16, paddingBottom: 0 }]}> 506 - <View style={[{ marginBottom: 16 }]}> 507 - <Text 508 - style={[ 509 - { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 510 - ]} 511 - > 512 - Stream Title 513 - </Text> 514 - <Textarea 515 - value={title} 516 - onChangeText={(text) => { 517 - setTitle(text); 518 - setError(null); 519 - }} 520 - placeholder="Enter stream title..." 521 - maxLength={140} 522 - multiline 523 - style={[ 524 - { 525 - padding: 12, 526 - borderRadius: 8, 527 - backgroundColor: atoms.colors.neutral[800], 528 - color: atoms.colors.white, 529 - borderWidth: 1, 530 - borderColor: atoms.colors.neutral[600], 531 - minHeight: 100, 532 - fontSize: 16, 533 - }, 534 - ]} 535 - /> 536 - <Text 537 - style={[ 538 - { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 539 - ]} 540 - > 541 - {title.length}/140 characters 542 - </Text> 543 - </View> 544 - 545 - {error && ( 546 - <View 547 - style={[ 548 - { 549 - backgroundColor: atoms.colors.red[900], 550 - padding: 12, 551 - borderRadius: 8, 552 - borderWidth: 1, 553 - borderColor: atoms.colors.red[700], 554 - marginBottom: 16, 555 - }, 556 - ]} 557 - > 558 - <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 559 - {error} 560 - </Text> 561 - </View> 562 - )} 563 - </View> 564 - 565 - <DialogFooter> 566 - <Button 567 - width="min" 568 - variant="secondary" 569 - onPress={() => { 570 - onClose(); 571 - setError(null); 572 - setTitle(livestream?.record?.title || ""); 573 - }} 574 - disabled={isLoading} 575 - > 576 - <Text>Cancel</Text> 577 - </Button> 578 - <Button 579 - variant="primary" 580 - width="min" 581 - onPress={handleUpdate} 582 - disabled={isLoading || !title.trim()} 583 - > 584 - <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 585 - </Button> 586 - </DialogFooter> 587 - </ResponsiveDialog> 588 - ); 589 - }
··· 5 import { 6 useCreateBlockRecord, 7 useCreateHideChatRecord, 8 } from "../../streamplace-store/block"; 9 import { 10 ModerationPermissions, ··· 16 import { ChatMessageViewHydrated } from "streamplace"; 17 import { 18 useDeleteChatMessage, 19 useLivestreamStore, 20 } from "../../livestream-store"; 21 import { useStreamplaceStore } from "../../streamplace-store"; 22 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; 23 import { 24 atoms, 25 DropdownMenu, 26 DropdownMenuGroup, 27 DropdownMenuItem, 28 DropdownMenuTrigger, 29 layout, 30 ResponsiveDropdownMenuContent, 31 Text, 32 useToast, 33 View, 34 } from "../ui"; ··· 55 let [messageRemoved, setMessageRemoved] = useState(false); 56 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 57 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 58 59 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 60 const setReportSubject = usePlayerStore((x) => x.setReportSubject); ··· 85 } 86 }, [message]); 87 88 // Check if any moderation actions are actually available for this message 89 // This must match the individual action checks inside the DropdownMenuGroup 90 const hasAvailableActions = !!( ··· 125 createHideChat={createHideChat} 126 createBlock={createBlock} 127 toast={toast} 128 setReportModalOpen={setReportModalOpen} 129 setReportSubject={setReportSubject} 130 deleteChatMessage={deleteChatMessage} ··· 132 )} 133 </ResponsiveDropdownMenuContent> 134 </DropdownMenu> 135 </> 136 ); 137 }); ··· 149 createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 150 createBlock: (did: string, streamerDID?: string) => Promise<any>; 151 toast: ReturnType<typeof useToast>; 152 setReportModalOpen: (open: boolean) => void; 153 setReportSubject: (subject: any) => void; 154 deleteChatMessage: (uri: string) => Promise<any>; ··· 167 createHideChat, 168 createBlock, 169 toast, 170 setReportModalOpen, 171 setReportSubject, 172 deleteChatMessage, ··· 260 </DropdownMenuGroup> 261 )} 262 263 <DropdownMenuGroup key="user-actions" title={`User actions`}> 264 <DropdownMenuItem 265 onPress={() => { ··· 270 > 271 <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 272 </DropdownMenuItem> 273 + {agent?.did && message.author.did === agent.did && ( 274 <DeleteButton 275 message={message} 276 deleteChatMessage={deleteChatMessage} 277 onOpenChange={onOpenChange} 278 /> 279 )} 280 + {(!agent?.did || message.author.did !== agent.did) && ( 281 <ReportButton 282 message={message} 283 setReportModalOpen={setReportModalOpen} ··· 371 </DropdownMenuItem> 372 ); 373 }
+169
js/components/src/components/chat/update-stream-title-dialog.tsx
···
··· 1 + import { useEffect, useState } from "react"; 2 + import { 3 + atoms, 4 + Button, 5 + DialogFooter, 6 + ResponsiveDialog, 7 + Text, 8 + Textarea, 9 + useToast, 10 + View, 11 + } from "../ui"; 12 + 13 + export interface UpdateStreamTitleDialogProps { 14 + livestream: any; 15 + streamerDID?: string; 16 + updateLivestream: ( 17 + livestreamUri: string, 18 + title: string, 19 + streamerDID?: string, 20 + ) => Promise<any>; 21 + isLoading: boolean; 22 + onClose: () => void; 23 + } 24 + 25 + export function UpdateStreamTitleDialog({ 26 + livestream, 27 + streamerDID, 28 + updateLivestream, 29 + isLoading, 30 + onClose, 31 + }: UpdateStreamTitleDialogProps) { 32 + const [title, setTitle] = useState(livestream?.record?.title || ""); 33 + const [error, setError] = useState<string | null>(null); 34 + const toast = useToast(); 35 + 36 + useEffect(() => { 37 + if (livestream?.record?.title) { 38 + setTitle(livestream.record.title); 39 + } 40 + }, [livestream?.record?.title]); 41 + 42 + const handleUpdate = async () => { 43 + setError(null); 44 + 45 + if (!title.trim()) { 46 + setError("Please enter a stream title"); 47 + return; 48 + } 49 + 50 + if (!livestream?.uri) { 51 + setError("No livestream found"); 52 + return; 53 + } 54 + 55 + try { 56 + await updateLivestream(livestream.uri, title.trim(), streamerDID); 57 + toast.show( 58 + "Stream title updated", 59 + "The stream title has been successfully updated.", 60 + { duration: 3 }, 61 + ); 62 + onClose(); 63 + } catch (err) { 64 + setError( 65 + err instanceof Error ? err.message : "Failed to update stream title", 66 + ); 67 + } 68 + }; 69 + 70 + return ( 71 + <ResponsiveDialog 72 + open={true} 73 + onOpenChange={(open) => { 74 + if (!open) { 75 + onClose(); 76 + setError(null); 77 + setTitle(livestream?.record?.title || ""); 78 + } 79 + }} 80 + title="Update Stream Title" 81 + description="Update the title of the livestream." 82 + size="md" 83 + dismissible={false} 84 + > 85 + <View style={[{ padding: 16, paddingBottom: 0 }]}> 86 + <View style={[{ marginBottom: 16 }]}> 87 + <Text 88 + style={[ 89 + { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 90 + ]} 91 + > 92 + Stream Title 93 + </Text> 94 + <Textarea 95 + value={title} 96 + onChangeText={(text) => { 97 + setTitle(text); 98 + setError(null); 99 + }} 100 + placeholder="Enter stream title..." 101 + maxLength={140} 102 + multiline 103 + style={[ 104 + { 105 + padding: 12, 106 + borderRadius: 8, 107 + backgroundColor: atoms.colors.neutral[800], 108 + color: atoms.colors.white, 109 + borderWidth: 1, 110 + borderColor: atoms.colors.neutral[600], 111 + minHeight: 100, 112 + fontSize: 16, 113 + }, 114 + ]} 115 + /> 116 + <Text 117 + style={[ 118 + { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 119 + ]} 120 + > 121 + {title.length}/140 characters 122 + </Text> 123 + </View> 124 + 125 + {error && ( 126 + <View 127 + style={[ 128 + { 129 + backgroundColor: atoms.colors.red[900], 130 + padding: 12, 131 + borderRadius: 8, 132 + borderWidth: 1, 133 + borderColor: atoms.colors.red[700], 134 + marginBottom: 16, 135 + }, 136 + ]} 137 + > 138 + <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 139 + {error} 140 + </Text> 141 + </View> 142 + )} 143 + </View> 144 + 145 + <DialogFooter> 146 + <Button 147 + width="min" 148 + variant="secondary" 149 + onPress={() => { 150 + onClose(); 151 + setError(null); 152 + setTitle(livestream?.record?.title || ""); 153 + }} 154 + disabled={isLoading} 155 + > 156 + <Text>Cancel</Text> 157 + </Button> 158 + <Button 159 + variant="primary" 160 + width="min" 161 + onPress={handleUpdate} 162 + disabled={isLoading || !title.trim()} 163 + > 164 + <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 165 + </Button> 166 + </DialogFooter> 167 + </ResponsiveDialog> 168 + ); 169 + }
+37 -10
js/components/src/components/content-metadata/content-metadata-form.tsx
··· 1 import { forwardRef, useCallback, useEffect, useState } from "react"; 2 - import { ScrollView, View } from "react-native"; 3 import { 4 CONTENT_WARNINGS, 5 LICENSE_OPTIONS, 6 } from "../../lib/metadata-constants"; 7 8 import { 9 PlaceStreamMetadataConfiguration, 10 PlaceStreamMetadataContentRights, ··· 21 } from "../../streamplace-store/streamplace-store"; 22 import { usePDSAgent } from "../../streamplace-store/xrpc"; 23 import * as zero from "../../ui"; 24 import { Button } from "../ui/button"; 25 import { Checkbox } from "../ui/checkbox"; 26 import { Input } from "../ui/input"; ··· 41 style?: any; 42 } 43 44 - // ButtonSelector component (same as in livestream-panel) 45 const ButtonSelector = ({ 46 values, 47 selectedValue, ··· 55 disabledValues?: string[]; 56 style?: any[]; 57 }) => ( 58 - <View style={[layout.flex.row, gap.all[1], ...style]}> 59 {values.map(({ label, value }) => ( 60 <Button 61 key={value} 62 variant={selectedValue === value ? "primary" : "secondary"} 63 size="pill" 64 disabled={disabledValues.includes(value)} 65 onPress={() => setSelectedValue(value)} 66 style={[ ··· 71 ]} 72 > 73 <Text 74 - style={[ 75 - selectedValue === value ? text.white : text.gray[300], 76 - { fontSize: 14, fontWeight: "600" }, 77 - ]} 78 > 79 {label} 80 </Text> ··· 93 const getContentMetadata = useGetContentMetadata(); 94 const saveContentMetadata = useSaveContentMetadata(); 95 const toast = useToast(); 96 97 // Local state for metadata 98 const [contentWarnings, setContentWarnings] = useState<string[]>([]); ··· 364 ]} 365 selectedValue={activeSection} 366 setSelectedValue={setActiveSection} 367 - style={[{ marginVertical: -2, flexDirection: "column" }]} 368 /> 369 </View> 370 ··· 379 gap.all[2], 380 ]} 381 > 382 - <Text>Content Warnings</Text> 383 - <Text muted>(optional)</Text> 384 </View> 385 <View style={[gap.all[2], w.percent[100]]}> 386 {CONTENT_WARNINGS.map((warning) => ( ··· 398 </View> 399 ))} 400 </View> 401 </View> 402 )} 403
··· 1 import { forwardRef, useCallback, useEffect, useState } from "react"; 2 + import { Linking, Pressable, ScrollView, View } from "react-native"; 3 import { 4 CONTENT_WARNINGS, 5 LICENSE_OPTIONS, 6 } from "../../lib/metadata-constants"; 7 8 + import { ExternalLink } from "lucide-react-native"; 9 import { 10 PlaceStreamMetadataConfiguration, 11 PlaceStreamMetadataContentRights, ··· 22 } from "../../streamplace-store/streamplace-store"; 23 import { usePDSAgent } from "../../streamplace-store/xrpc"; 24 import * as zero from "../../ui"; 25 + import { Admonition } from "../ui"; 26 import { Button } from "../ui/button"; 27 import { Checkbox } from "../ui/checkbox"; 28 import { Input } from "../ui/input"; ··· 43 style?: any; 44 } 45 46 const ButtonSelector = ({ 47 values, 48 selectedValue, ··· 56 disabledValues?: string[]; 57 style?: any[]; 58 }) => ( 59 + <View style={[layout.flex.row, gap.all[1], layout.flex.wrap.wrap, ...style]}> 60 {values.map(({ label, value }) => ( 61 <Button 62 key={value} 63 variant={selectedValue === value ? "primary" : "secondary"} 64 size="pill" 65 + width="min" 66 disabled={disabledValues.includes(value)} 67 onPress={() => setSelectedValue(value)} 68 style={[ ··· 73 ]} 74 > 75 <Text 76 + size="sm" 77 + style={[selectedValue === value ? text.white : text.gray[300]]} 78 > 79 {label} 80 </Text> ··· 93 const getContentMetadata = useGetContentMetadata(); 94 const saveContentMetadata = useSaveContentMetadata(); 95 const toast = useToast(); 96 + const th = zero.useTheme(); 97 98 // Local state for metadata 99 const [contentWarnings, setContentWarnings] = useState<string[]>([]); ··· 365 ]} 366 selectedValue={activeSection} 367 setSelectedValue={setActiveSection} 368 + style={[{ marginVertical: -2 }]} 369 /> 370 </View> 371 ··· 380 gap.all[2], 381 ]} 382 > 383 + <Text size="lg">Content Warnings</Text> 384 </View> 385 <View style={[gap.all[2], w.percent[100]]}> 386 {CONTENT_WARNINGS.map((warning) => ( ··· 398 </View> 399 ))} 400 </View> 401 + <Admonition variant="info" size="sm"> 402 + <Text size="sm"> 403 + You are required to disclose if your content is not suitable 404 + for certain viewers. 405 + </Text> 406 + </Admonition> 407 + <Admonition variant="warning" size="sm"> 408 + <Text size="sm"> 409 + Your node may prohibit some of this content. Read the 410 + community guidelines to make sure.{" "} 411 + <Pressable 412 + onPress={() => 413 + Linking.openURL( 414 + "https://blog.stream.place/3mcqwibo4ks2w", 415 + ) 416 + } 417 + > 418 + <Text size="sm" color={zero.colors.blue[400]}> 419 + Learn more{" "} 420 + <ExternalLink 421 + size="14" 422 + style={{ marginVertical: -2 }} 423 + /> 424 + </Text> 425 + </Pressable> 426 + </Text> 427 + </Admonition> 428 </View> 429 )} 430
+1 -14
js/components/src/components/dashboard/header.tsx
··· 1 - import { AlertCircle, Car, Radio, Users } from "lucide-react-native"; 2 import { Pressable, Text, View } from "react-native"; 3 import * as zero from "../../ui"; 4 ··· 98 interface HeaderProps { 99 isLive: boolean; 100 streamTitle?: string; 101 - viewers?: number; 102 uptime?: string; 103 bitrate?: string; 104 timeBetweenSegments?: number; ··· 110 export default function Header({ 111 isLive, 112 streamTitle = "Live Stream", 113 - viewers = 0, 114 uptime = "00:00:00", 115 bitrate = "0 mbps", 116 timeBetweenSegments = 0, ··· 179 180 {/* Right side - Stream metrics */} 181 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[6]]}> 182 - {isLive && ( 183 - <> 184 - <MetricItem 185 - icon={Users} 186 - label="Viewers" 187 - value={viewers.toLocaleString()} 188 - /> 189 - <MetricItem icon={Car} label="Bitrate" value={bitrate} /> 190 - </> 191 - )} 192 - 193 {!isLive && ( 194 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 195 <Radio size={16} color="#6b7280" />
··· 1 + import { AlertCircle, Radio } from "lucide-react-native"; 2 import { Pressable, Text, View } from "react-native"; 3 import * as zero from "../../ui"; 4 ··· 98 interface HeaderProps { 99 isLive: boolean; 100 streamTitle?: string; 101 uptime?: string; 102 bitrate?: string; 103 timeBetweenSegments?: number; ··· 109 export default function Header({ 110 isLive, 111 streamTitle = "Live Stream", 112 uptime = "00:00:00", 113 bitrate = "0 mbps", 114 timeBetweenSegments = 0, ··· 177 178 {/* Right side - Stream metrics */} 179 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[6]]}> 180 {!isLive && ( 181 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 182 <Radio size={16} color="#6b7280" />
+2 -1
js/components/src/components/dashboard/information-widget.tsx
··· 12 import React, { useCallback, useEffect, useMemo, useState } from "react"; 13 import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; 14 import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; 15 import { 16 useLivestreamStore, 17 useSegment, ··· 38 const [bitrateHistory, setBitrateHistory] = useState<number[]>( 39 Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0), 40 ); 41 - const [showViewers, setShowViewers] = useState(false); 42 const [componentWidth, setComponentWidth] = useState<number>(220); 43 const [componentHeight, setComponentHeight] = useState<number>(400); 44 const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
··· 12 import React, { useCallback, useEffect, useMemo, useState } from "react"; 13 import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; 14 import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; 15 + import { useAQState } from "../../hooks"; 16 import { 17 useLivestreamStore, 18 useSegment, ··· 39 const [bitrateHistory, setBitrateHistory] = useState<number[]>( 40 Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0), 41 ); 42 + const [showViewers, setShowViewers] = useAQState("showViewers", true); 43 const [componentWidth, setComponentWidth] = useState<number>(220); 44 const [componentHeight, setComponentHeight] = useState<number>(400); 45 const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
+22 -1
js/components/src/components/mobile-player/player.tsx
··· 5 PlayerStatusTracker, 6 usePlayerStore, 7 } from "../../player-store"; 8 - import { useStreamplaceStore } from "../../streamplace-store"; 9 import { Text, View } from "../ui"; 10 import { Fullscreen } from "./fullscreen"; 11 import { PlayerProps } from "./props"; ··· 28 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 29 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 30 const reportSubject = usePlayerStore((x) => x.reportSubject); 31 32 useEffect(() => { 33 setReportingURL(props.reportingURL ?? null);
··· 5 PlayerStatusTracker, 6 usePlayerStore, 7 } from "../../player-store"; 8 + import { 9 + useMuted, 10 + useSetMuted, 11 + useStreamplaceStore, 12 + } from "../../streamplace-store"; 13 import { Text, View } from "../ui"; 14 import { Fullscreen } from "./fullscreen"; 15 import { PlayerProps } from "./props"; ··· 32 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 33 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 34 const reportSubject = usePlayerStore((x) => x.reportSubject); 35 + 36 + const setMuted = useSetMuted(); 37 + const muted = useMuted(); 38 + 39 + // if we set muted, set it and restore after 40 + useEffect(() => { 41 + let wasMuted: null | boolean = null; 42 + setTimeout(() => { 43 + if (props.muted != undefined) { 44 + wasMuted = muted; 45 + setMuted(props.muted); 46 + } 47 + }, 200); 48 + return () => { 49 + wasMuted !== null && setMuted(wasMuted); 50 + }; 51 + }, [props.muted]); 52 53 useEffect(() => { 54 setReportingURL(props.reportingURL ?? null);
+42 -8
js/components/src/components/mobile-player/ui/input.tsx
··· 9 setTitle: (title: string) => void; 10 ingestStarting: boolean; 11 toggleGoLive: () => void; 12 }; 13 14 export function InputPanel({ ··· 16 setTitle, 17 ingestStarting, 18 toggleGoLive, 19 }: InputPanelProps) { 20 const { slideKeyboard } = useKeyboardSlide(); 21 return ( ··· 37 { padding: 10 }, 38 ]} 39 > 40 - <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 - <Input 42 - value={title} 43 - onChange={setTitle} 44 - placeholder="Enter stream title" 45 - onEndEditing={Keyboard.dismiss} 46 - /> 47 - </View> 48 {ingestStarting ? ( 49 <Text>Starting your stream...</Text> 50 ) : ( 51 <View style={[layout.flex.center]}> 52 <Pressable
··· 9 setTitle: (title: string) => void; 10 ingestStarting: boolean; 11 toggleGoLive: () => void; 12 + isLive: boolean; 13 + toggleStopStream?: () => void; 14 }; 15 16 export function InputPanel({ ··· 18 setTitle, 19 ingestStarting, 20 toggleGoLive, 21 + isLive, 22 + toggleStopStream, 23 }: InputPanelProps) { 24 const { slideKeyboard } = useKeyboardSlide(); 25 return ( ··· 41 { padding: 10 }, 42 ]} 43 > 44 + {!isLive && ( 45 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 46 + <Input 47 + value={title} 48 + onChange={setTitle} 49 + placeholder="Enter stream title" 50 + onEndEditing={Keyboard.dismiss} 51 + /> 52 + </View> 53 + )} 54 {ingestStarting ? ( 55 <Text>Starting your stream...</Text> 56 + ) : isLive ? ( 57 + <View style={[layout.flex.center]}> 58 + <Pressable 59 + onPress={toggleStopStream} 60 + style={[ 61 + px[4], 62 + py[2], 63 + layout.flex.row, 64 + layout.flex.center, 65 + gap.all[1], 66 + { 67 + backgroundColor: "rgba(64,64,64, 0.8)", 68 + borderRadius: 12, 69 + }, 70 + ]} 71 + > 72 + <View 73 + style={[ 74 + p[2], 75 + { 76 + backgroundColor: "rgba(256,0,0, 0.8)", 77 + borderRadius: 12, 78 + }, 79 + ]} 80 + /> 81 + <Text center>Stop Stream</Text> 82 + </Pressable> 83 + </View> 84 ) : ( 85 <View style={[layout.flex.center]}> 86 <Pressable
+2
js/components/src/components/mobile-player/ui/report-modal.tsx
··· 173 </View> 174 <DialogFooter> 175 <Button 176 variant="secondary" 177 onPress={handleCancel} 178 disabled={isSubmitting} ··· 180 <Text>Cancel</Text> 181 </Button> 182 <Button 183 variant="primary" 184 onPress={handleSubmit} 185 disabled={!selectedReason || isSubmitting}
··· 173 </View> 174 <DialogFooter> 175 <Button 176 + width="min" 177 variant="secondary" 178 onPress={handleCancel} 179 disabled={isSubmitting} ··· 181 <Text>Cancel</Text> 182 </Button> 183 <Button 184 + width="min" 185 variant="primary" 186 onPress={handleSubmit} 187 disabled={!selectedReason || isSubmitting}
+138 -2
js/components/src/components/mobile-player/ui/streamer-context-menu.tsx
··· 1 - export function StreamContextMenu() { 2 - return <></>; 3 }
··· 1 + import { ChevronRight, Cog } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withDelay, 8 + withSequence, 9 + withTiming, 10 + } from "react-native-reanimated"; 11 + import { useLivestreamInfo, zero } from "../../.."; 12 + import { usePlayerStore } from "../../../player-store"; 13 + import { 14 + DropdownMenu, 15 + DropdownMenuCheckboxItem, 16 + DropdownMenuGroup, 17 + DropdownMenuItem, 18 + DropdownMenuTrigger, 19 + ResponsiveDropdownMenuContent, 20 + Text, 21 + useTheme, 22 + } from "../../ui"; 23 + 24 + export function StreamContextMenu({ 25 + dropdownPortalContainer, 26 + }: { 27 + dropdownPortalContainer?: string; 28 + }) { 29 + const th = useTheme(); 30 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 31 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 32 + const { toggleStopStream } = useLivestreamInfo(); 33 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 34 + const isLive = ingest !== null && ingest !== "new"; 35 + 36 + const [isOpen, setIsOpen] = useState(false); 37 + const [hasShownTooltip, setHasShownTooltip] = useState(false); 38 + 39 + const tooltipOpacity = useSharedValue(0); 40 + const tooltipTranslateX = useSharedValue(20); 41 + 42 + useEffect(() => { 43 + if (isLive && !hasShownTooltip) { 44 + tooltipOpacity.value = withDelay( 45 + 500, 46 + withSequence( 47 + withTiming(1, { duration: 300 }), 48 + withDelay(10000, withTiming(0, { duration: 300 })), 49 + ), 50 + ); 51 + tooltipTranslateX.value = withDelay( 52 + 500, 53 + withSequence( 54 + withTiming(0, { duration: 300 }), 55 + withDelay(10000, withTiming(20, { duration: 300 })), 56 + ), 57 + ); 58 + setHasShownTooltip(true); 59 + } 60 + }, [isLive, hasShownTooltip]); 61 + 62 + const iconRotate = useAnimatedStyle(() => { 63 + return { 64 + transform: [ 65 + { 66 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 67 + duration: 650, 68 + easing: Easing.out(Easing.ease), 69 + }), 70 + }, 71 + ], 72 + }; 73 + }); 74 + 75 + const tooltipStyle = useAnimatedStyle(() => { 76 + return { 77 + opacity: tooltipOpacity.value, 78 + transform: [{ translateX: tooltipTranslateX.value }], 79 + }; 80 + }); 81 + 82 + return ( 83 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 84 + <DropdownMenuTrigger> 85 + <Animated.View style={[iconRotate]}> 86 + <Cog color={th.theme.colors.foreground} /> 87 + </Animated.View> 88 + <Animated.View 89 + style={[ 90 + tooltipStyle, 91 + { 92 + position: "absolute", 93 + right: 30, 94 + top: 0, 95 + backgroundColor: "rgba(64,64,64,0.95)", 96 + borderRadius: 8, 97 + paddingHorizontal: 8, 98 + paddingRight: 12, 99 + paddingVertical: 4, 100 + flexDirection: "row", 101 + alignItems: "center", 102 + gap: 6, 103 + zIndex: 9999999, 104 + pointerEvents: "box-none", 105 + width: 120, 106 + }, 107 + ]} 108 + > 109 + <Text size="sm" color="white"> 110 + End stream here 111 + </Text> 112 + <ChevronRight color="white" size={16} style={[zero.mr[4]]} /> 113 + </Animated.View> 114 + </DropdownMenuTrigger> 115 + <ResponsiveDropdownMenuContent side="top" align="end"> 116 + {isLive && ( 117 + <DropdownMenuGroup title="Stream"> 118 + <DropdownMenuItem 119 + closeOnPress={true} 120 + onPress={() => { 121 + toggleStopStream(); 122 + }} 123 + > 124 + <Text color="destructive">Stop Stream</Text> 125 + </DropdownMenuItem> 126 + </DropdownMenuGroup> 127 + )} 128 + <DropdownMenuGroup title="Advanced"> 129 + <DropdownMenuCheckboxItem 130 + checked={debugInfo} 131 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 132 + > 133 + <Text>Show Debug Info</Text> 134 + </DropdownMenuCheckboxItem> 135 + </DropdownMenuGroup> 136 + </ResponsiveDropdownMenuContent> 137 + </DropdownMenu> 138 + ); 139 }
-1
js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx
··· 52 position: "absolute", 53 width: "100%", 54 height: "100%", 55 - zIndex: 998, 56 alignItems: "center", 57 justifyContent: "center", 58 backgroundColor: "rgba(0,0,0,0.3)",
··· 52 position: "absolute", 53 width: "100%", 54 height: "100%", 55 alignItems: "center", 56 justifyContent: "center", 57 backgroundColor: "rgba(0,0,0,0.3)",
+177
js/components/src/components/ui/admonition.tsx
···
··· 1 + import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react-native"; 2 + import { View, ViewStyle } from "react-native"; 3 + import { useTheme } from "../../ui"; 4 + import { Text } from "./text"; 5 + 6 + type AdmonitionVariant = "default" | "success" | "error" | "info" | "warning"; 7 + type AdmonitionSize = "sm" | "md" | "lg"; 8 + 9 + type AdmonitionProps = { 10 + variant?: AdmonitionVariant; 11 + size?: AdmonitionSize; 12 + title?: string; 13 + children?: React.ReactNode; 14 + iconLeft?: React.ComponentType<any>; 15 + style?: ViewStyle; 16 + }; 17 + 18 + export function Admonition({ 19 + variant = "default", 20 + size = "md", 21 + title, 22 + children, 23 + iconLeft, 24 + style, 25 + }: AdmonitionProps) { 26 + const { theme, icons } = useTheme(); 27 + 28 + const defaultIconLeft = (() => { 29 + if (iconLeft) return iconLeft; 30 + switch (variant) { 31 + case "success": 32 + return CheckCircle; 33 + case "error": 34 + return XCircle; 35 + case "info": 36 + return Info; 37 + case "warning": 38 + return AlertCircle; 39 + default: 40 + return Info; 41 + } 42 + })(); 43 + 44 + const FinalIconLeft = defaultIconLeft; 45 + 46 + const variantStyles: Record<AdmonitionVariant, ViewStyle> = { 47 + default: { 48 + backgroundColor: theme.colors.secondary, 49 + borderColor: theme.colors.border, 50 + }, 51 + success: { 52 + backgroundColor: theme.colors.success + "15", 53 + borderColor: theme.colors.success, 54 + }, 55 + error: { 56 + backgroundColor: theme.colors.destructive + "15", 57 + borderColor: theme.colors.destructive, 58 + }, 59 + info: { 60 + backgroundColor: theme.colors.info + "15", 61 + borderColor: theme.colors.info, 62 + }, 63 + warning: { 64 + backgroundColor: theme.colors.warning + "15", 65 + borderColor: theme.colors.warning, 66 + }, 67 + }; 68 + 69 + const iconColor = (() => { 70 + switch (variant) { 71 + case "success": 72 + return theme.colors.success; 73 + case "error": 74 + return theme.colors.destructive; 75 + case "info": 76 + return theme.colors.info; 77 + case "warning": 78 + return theme.colors.warning; 79 + default: 80 + return theme.colors.foreground; 81 + } 82 + })(); 83 + 84 + const sizeConfig = (() => { 85 + switch (size) { 86 + case "sm": 87 + return { 88 + borderRadius: 8, 89 + padding: 12, 90 + gap: 6, 91 + iconSize: icons.size.md, 92 + titleSize: "base" as const, 93 + contentSize: "sm" as const, 94 + innerGap: 8, 95 + }; 96 + case "lg": 97 + return { 98 + borderRadius: 16, 99 + padding: 20, 100 + gap: 12, 101 + iconSize: icons.size.xl, 102 + titleSize: "xl" as const, 103 + contentSize: "lg" as const, 104 + innerGap: 16, 105 + }; 106 + case "md": 107 + default: 108 + return { 109 + borderRadius: 12, 110 + padding: 16, 111 + gap: 8, 112 + iconSize: icons.size.lg, 113 + titleSize: "lg" as const, 114 + contentSize: "base" as const, 115 + innerGap: 12, 116 + }; 117 + } 118 + })(); 119 + 120 + let childrenIn = ( 121 + <View 122 + style={{ 123 + paddingLeft: title ? sizeConfig.iconSize + sizeConfig.innerGap : 0, 124 + }} 125 + > 126 + {typeof children === "string" ? ( 127 + <Text 128 + size={sizeConfig.contentSize} 129 + style={{ color: theme.colors.cardForeground, flexWrap: "wrap" }} 130 + > 131 + {children} 132 + </Text> 133 + ) : ( 134 + children 135 + )} 136 + </View> 137 + ); 138 + 139 + return ( 140 + <View 141 + style={[ 142 + { 143 + borderRadius: sizeConfig.borderRadius, 144 + borderWidth: 1, 145 + padding: sizeConfig.padding, 146 + gap: sizeConfig.gap, 147 + }, 148 + variantStyles[variant], 149 + style, 150 + ]} 151 + > 152 + <View 153 + style={{ 154 + flexDirection: "row", 155 + alignItems: "flex-start", 156 + gap: sizeConfig.innerGap, 157 + }} 158 + > 159 + {FinalIconLeft && ( 160 + <FinalIconLeft size={sizeConfig.iconSize} color={iconColor} /> 161 + )} 162 + {title ? ( 163 + <Text 164 + size={sizeConfig.titleSize} 165 + weight="semibold" 166 + style={{ flex: 1 }} 167 + > 168 + {title} 169 + </Text> 170 + ) : ( 171 + children && <View style={{ flex: 1 }}>{childrenIn}</View> 172 + )} 173 + </View> 174 + {children && title && childrenIn} 175 + </View> 176 + ); 177 + }
+3
js/components/src/components/ui/button.tsx
··· 41 loading?: boolean; 42 loadingText?: string; 43 width?: "full" | "min" | number; 44 } 45 46 export const Button = forwardRef<any, ButtonProps>( ··· 56 disabled, 57 style, 58 width = "full", 59 ...props 60 }, 61 ref, ··· 222 ref={ref} 223 disabled={disabled || loading} 224 style={[buttonStyle, sizeStyles.button, widthStyle, style]} 225 {...props} 226 > 227 <ButtonPrimitive.Content style={sizeStyles.inner}>
··· 41 loading?: boolean; 42 loadingText?: string; 43 width?: "full" | "min" | number; 44 + hoverStyle?: ButtonPrimitiveProps["hoverStyle"]; 45 } 46 47 export const Button = forwardRef<any, ButtonProps>( ··· 57 disabled, 58 style, 59 width = "full", 60 + hoverStyle, 61 ...props 62 }, 63 ref, ··· 224 ref={ref} 225 disabled={disabled || loading} 226 style={[buttonStyle, sizeStyles.button, widthStyle, style]} 227 + hoverStyle={hoverStyle} 228 {...props} 229 > 230 <ButtonPrimitive.Content style={sizeStyles.inner}>
+13 -11
js/components/src/components/ui/dropdown.tsx
··· 537 ({ description, ...props }, ref) => { 538 const { theme } = useTheme(); 539 return ( 540 - <Text 541 - style={[ 542 - { color: theme.colors.textMuted }, 543 - pt[1], 544 - pl[2], 545 - pb[2], 546 - fontSize.sm, 547 - ]} 548 - > 549 - {description} 550 - </Text> 551 ); 552 }, 553 );
··· 537 ({ description, ...props }, ref) => { 538 const { theme } = useTheme(); 539 return ( 540 + <View ref={ref} {...props}> 541 + <Text 542 + style={[ 543 + { color: theme.colors.textMuted }, 544 + pt[1], 545 + pl[2], 546 + pb[2], 547 + fontSize.sm, 548 + ]} 549 + > 550 + {description} 551 + </Text> 552 + </View> 553 ); 554 }, 555 );
+1
js/components/src/components/ui/index.ts
··· 5 export * from "./primitives/text"; 6 7 // Export styled components 8 export * from "./button"; 9 export * from "./checkbox"; 10 export * from "./dialog";
··· 5 export * from "./primitives/text"; 6 7 // Export styled components 8 + export * from "./admonition"; 9 export * from "./button"; 10 export * from "./checkbox"; 11 export * from "./dialog";
+37 -11
js/components/src/components/ui/primitives/button.tsx
··· 1 - import React, { forwardRef } from "react"; 2 import { 3 AccessibilityRole, 4 GestureResponderEvent, 5 StyleSheet, 6 Text, 7 TextProps, 8 - TouchableOpacity, 9 - TouchableOpacityProps, 10 View, 11 ViewProps, 12 } from "react-native"; 13 14 // Base button primitive interface 15 - export interface ButtonPrimitiveProps 16 - extends Omit<TouchableOpacityProps, "onPress"> { 17 onPress?: (event: GestureResponderEvent) => void; 18 disabled?: boolean; 19 loading?: boolean; ··· 21 accessibilityLabel?: string; 22 accessibilityHint?: string; 23 testID?: string; 24 } 25 26 // Button root primitive - handles all touch interactions 27 export const ButtonRoot = forwardRef< 28 - React.ComponentRef<typeof TouchableOpacity>, 29 ButtonPrimitiveProps 30 >( 31 ( ··· 43 accessibilityState, 44 testID, 45 style, 46 - activeOpacity = 0.7, 47 ...props 48 }, 49 ref, 50 ) => { 51 const handlePress = React.useCallback( 52 (event: GestureResponderEvent) => { 53 if (!disabled && !loading && onPress) { ··· 84 [disabled, loading, onLongPress], 85 ); 86 87 return ( 88 - <TouchableOpacity 89 ref={ref} 90 onPress={handlePress} 91 onPressIn={handlePressIn} 92 onPressOut={handlePressOut} 93 onLongPress={handleLongPress} 94 disabled={disabled || loading} 95 - activeOpacity={disabled || loading ? 1 : activeOpacity} 96 accessibilityRole={accessibilityRole} 97 accessibilityLabel={accessibilityLabel} 98 accessibilityHint={accessibilityHint} ··· 104 testID={testID} 105 style={[ 106 primitiveStyles.button, 107 (disabled || loading) && primitiveStyles.disabled, 108 - style, 109 ]} 110 {...props} 111 > 112 {children} 113 - </TouchableOpacity> 114 ); 115 }, 116 ); ··· 245 alignItems: "center", 246 justifyContent: "center", 247 }, 248 disabled: { 249 opacity: 0.5, 250 },
··· 1 + import React, { forwardRef, useState } from "react"; 2 import { 3 AccessibilityRole, 4 GestureResponderEvent, 5 + Platform, 6 + Pressable, 7 + PressableProps, 8 + StyleProp, 9 StyleSheet, 10 Text, 11 TextProps, 12 View, 13 ViewProps, 14 + ViewStyle, 15 } from "react-native"; 16 17 // Base button primitive interface 18 + export interface ButtonPrimitiveProps extends Omit<PressableProps, "onPress"> { 19 onPress?: (event: GestureResponderEvent) => void; 20 disabled?: boolean; 21 loading?: boolean; ··· 23 accessibilityLabel?: string; 24 accessibilityHint?: string; 25 testID?: string; 26 + hoverStyle?: StyleProp<ViewStyle>; 27 } 28 29 // Button root primitive - handles all touch interactions 30 export const ButtonRoot = forwardRef< 31 + React.ComponentRef<typeof Pressable>, 32 ButtonPrimitiveProps 33 >( 34 ( ··· 46 accessibilityState, 47 testID, 48 style, 49 + hoverStyle, 50 ...props 51 }, 52 ref, 53 ) => { 54 + const [isHovered, setIsHovered] = useState(false); 55 + 56 const handlePress = React.useCallback( 57 (event: GestureResponderEvent) => { 58 if (!disabled && !loading && onPress) { ··· 89 [disabled, loading, onLongPress], 90 ); 91 92 + const handleHoverIn = React.useCallback(() => { 93 + if (!disabled && !loading) { 94 + setIsHovered(true); 95 + } 96 + }, [disabled, loading]); 97 + 98 + const handleHoverOut = React.useCallback(() => { 99 + setIsHovered(false); 100 + }, []); 101 + 102 return ( 103 + <Pressable 104 ref={ref} 105 onPress={handlePress} 106 onPressIn={handlePressIn} 107 onPressOut={handlePressOut} 108 onLongPress={handleLongPress} 109 + onHoverIn={handleHoverIn} 110 + onHoverOut={handleHoverOut} 111 disabled={disabled || loading} 112 accessibilityRole={accessibilityRole} 113 accessibilityLabel={accessibilityLabel} 114 accessibilityHint={accessibilityHint} ··· 120 testID={testID} 121 style={[ 122 primitiveStyles.button, 123 + primitiveStyles.transition, 124 (disabled || loading) && primitiveStyles.disabled, 125 + style as any, 126 + isHovered && hoverStyle, 127 ]} 128 {...props} 129 > 130 {children} 131 + </Pressable> 132 ); 133 }, 134 ); ··· 263 alignItems: "center", 264 justifyContent: "center", 265 }, 266 + transition: 267 + Platform.OS === "web" 268 + ? // probably fine if web-only 269 + ({ 270 + transitionDuration: "150ms", 271 + transitionProperty: "background-color, border-color, color", 272 + } as any) 273 + : undefined, 274 disabled: { 275 opacity: 0.5, 276 },
+2 -1
js/components/src/components/ui/resizeable.tsx
··· 95 translateY: 96 slideKeyboard + 97 Math.max(0, -sheetHeight.value) + 98 - (slideKeyboard < 0 ? 0 : -safeBottom), 99 }, 100 ], 101 }));
··· 95 translateY: 96 slideKeyboard + 97 Math.max(0, -sheetHeight.value) + 98 + (slideKeyboard < 0 ? 0 : -safeBottom) - 99 + (Math.abs(slideKeyboard) > 1 ? 32 : 16), 100 }, 101 ], 102 }));
+11 -1
js/components/src/components/ui/text.tsx
··· 62 63 export interface TextProps 64 extends Omit<TextPrimitiveProps, "variant" | "size" | "weight" | "color">, 65 - VariantProps<typeof textVariants> { 66 // Additional convenience props 67 muted?: boolean; 68 bold?: boolean;
··· 62 63 export interface TextProps 64 extends Omit<TextPrimitiveProps, "variant" | "size" | "weight" | "color">, 65 + Omit<VariantProps<typeof textVariants>, "color"> { 66 + // Override color to accept hex values and custom strings 67 + color?: 68 + | "default" 69 + | "muted" 70 + | "primary" 71 + | "secondary" 72 + | "destructive" 73 + | "success" 74 + | "warning" 75 + | (string & {}); 76 // Additional convenience props 77 muted?: boolean; 78 bold?: boolean;
+5
js/components/src/components/ui/textarea.tsx
··· 5 import * as React from "react"; 6 import { Platform, TextInput, type TextInputProps } from "react-native"; 7 import { bg, borders, flex, p, text } from "../../lib/theme/atoms"; 8 9 const Textarea = React.forwardRef<TextInput, TextInputProps>( 10 ({ style, multiline = true, numberOfLines = 4, ...props }, ref) => { 11 // Detect if we're inside a bottom sheet 12 let isInBottomSheet = false; 13 try { ··· 38 { borderRadius: 10 }, 39 style, 40 ]} 41 multiline={multiline} 42 numberOfLines={numberOfLines} 43 textAlignVertical="top" 44 {...props} 45 /> 46 );
··· 5 import * as React from "react"; 6 import { Platform, TextInput, type TextInputProps } from "react-native"; 7 import { bg, borders, flex, p, text } from "../../lib/theme/atoms"; 8 + import { useTheme } from "../../ui"; 9 10 const Textarea = React.forwardRef<TextInput, TextInputProps>( 11 ({ style, multiline = true, numberOfLines = 4, ...props }, ref) => { 12 + let th = useTheme(); 13 // Detect if we're inside a bottom sheet 14 let isInBottomSheet = false; 15 try { ··· 40 { borderRadius: 10 }, 41 style, 42 ]} 43 + autoComplete={props.autoComplete || "off"} 44 + textContentType={props.textContentType || "none"} 45 multiline={multiline} 46 numberOfLines={numberOfLines} 47 textAlignVertical="top" 48 + placeholderTextColor={th.theme.colors.textMuted} 49 {...props} 50 /> 51 );
+1
js/components/src/hooks/index.ts
··· 1 // barrel file :) 2 export * from "./useAvatars"; 3 export * from "./useCameraToggle"; 4 export * from "./useDocumentTitle";
··· 1 // barrel file :) 2 + export * from "./useAQState"; 3 export * from "./useAvatars"; 4 export * from "./useCameraToggle"; 5 export * from "./useDocumentTitle";
+37
js/components/src/hooks/useAQState.ts
···
··· 1 + import { useEffect, useState } from "react"; 2 + import storage from "../storage"; 3 + 4 + export function useAQState<T>( 5 + key: string, 6 + defaultValue: T, 7 + ): [T, (value: T) => void] { 8 + const [state, setState] = useState<T>(defaultValue); 9 + const [isLoaded, setIsLoaded] = useState(false); 10 + 11 + useEffect(() => { 12 + const loadFromStorage = async () => { 13 + try { 14 + const stored = await storage.getItem(key); 15 + if (stored !== null) { 16 + setState(JSON.parse(stored)); 17 + } 18 + } catch (error) { 19 + console.error(`Failed to load ${key} from storage:`, error); 20 + } finally { 21 + setIsLoaded(true); 22 + } 23 + }; 24 + loadFromStorage(); 25 + }, [key]); 26 + 27 + const setStoredState = (value: T) => { 28 + setState(value); 29 + if (isLoaded) { 30 + storage.setItem(key, JSON.stringify(value)).catch((error) => { 31 + console.error(`Failed to save ${key} to storage:`, error); 32 + }); 33 + } 34 + }; 35 + 36 + return [state, setStoredState]; 37 + }
+8
js/components/src/hooks/useLivestreamInfo.ts
··· 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 13 const createStreamRecord = useCreateStreamRecord(); 14 ··· 54 } 55 }; 56 57 return { 58 ingest, 59 profile, ··· 67 setIngestStarting, 68 handleSubmit, 69 toggleGoLive, 70 }; 71 }
··· 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 + const stopIngest = usePlayerStore((x) => x.stopIngest); 13 14 const createStreamRecord = useCreateStreamRecord(); 15 ··· 55 } 56 }; 57 58 + // Stop the current broadcast 59 + const toggleStopStream = () => { 60 + console.log("Stopping stream..."); 61 + stopIngest(); 62 + }; 63 + 64 return { 65 ingest, 66 profile, ··· 74 setIngestStarting, 75 handleSubmit, 76 toggleGoLive, 77 + toggleStopStream, 78 }; 79 }
+10
js/components/src/i18n/i18n-loader.native.ts
··· 2 // Metro will use this file for React Native builds 3 4 // Import all translations directly so they're bundled into the app 5 import enUSCommon from "../../public/locales/en-US/common.json"; 6 import enUSSettings from "../../public/locales/en-US/settings.json"; 7 import esESCommon from "../../public/locales/es-ES/common.json"; 8 import esESSettings from "../../public/locales/es-ES/settings.json"; 9 import frFRCommon from "../../public/locales/fr-FR/common.json"; 10 import frFRSettings from "../../public/locales/fr-FR/settings.json"; 11 import ptBRCommon from "../../public/locales/pt-BR/common.json"; 12 import ptBRSettings from "../../public/locales/pt-BR/settings.json"; 13 import zhHantCommon from "../../public/locales/zh-Hant/common.json"; 14 import zhHantSettings from "../../public/locales/zh-Hant/settings.json"; 15 16 const translationMap: Record<string, any> = { 17 "en-US/common": enUSCommon, 18 "en-US/settings": enUSSettings, 19 "pt-BR/common": ptBRCommon, 20 "pt-BR/settings": ptBRSettings, 21 "es-ES/common": esESCommon, 22 "es-ES/settings": esESSettings, 23 "zh-Hant/common": zhHantCommon, 24 "zh-Hant/settings": zhHantSettings, 25 "fr-FR/common": frFRCommon, 26 "fr-FR/settings": frFRSettings, 27 };
··· 2 // Metro will use this file for React Native builds 3 4 // Import all translations directly so they're bundled into the app 5 + import enUSChat from "../../public/locales/en-US/chat.json"; 6 import enUSCommon from "../../public/locales/en-US/common.json"; 7 import enUSSettings from "../../public/locales/en-US/settings.json"; 8 + import esESChat from "../../public/locales/es-ES/chat.json"; 9 import esESCommon from "../../public/locales/es-ES/common.json"; 10 import esESSettings from "../../public/locales/es-ES/settings.json"; 11 + import frFRChat from "../../public/locales/fr-FR/chat.json"; 12 import frFRCommon from "../../public/locales/fr-FR/common.json"; 13 import frFRSettings from "../../public/locales/fr-FR/settings.json"; 14 + import ptBRChat from "../../public/locales/pt-BR/chat.json"; 15 import ptBRCommon from "../../public/locales/pt-BR/common.json"; 16 import ptBRSettings from "../../public/locales/pt-BR/settings.json"; 17 + import zhHantChat from "../../public/locales/zh-Hant/chat.json"; 18 import zhHantCommon from "../../public/locales/zh-Hant/common.json"; 19 import zhHantSettings from "../../public/locales/zh-Hant/settings.json"; 20 21 const translationMap: Record<string, any> = { 22 + "en-US/chat": enUSChat, 23 "en-US/common": enUSCommon, 24 "en-US/settings": enUSSettings, 25 + "pt-BR/chat": ptBRChat, 26 "pt-BR/common": ptBRCommon, 27 "pt-BR/settings": ptBRSettings, 28 + "es-ES/chat": esESChat, 29 "es-ES/common": esESCommon, 30 "es-ES/settings": esESSettings, 31 + "zh-Hant/chat": zhHantChat, 32 "zh-Hant/common": zhHantCommon, 33 "zh-Hant/settings": zhHantSettings, 34 + "fr-FR/chat": frFRChat, 35 "fr-FR/common": frFRCommon, 36 "fr-FR/settings": frFRSettings, 37 };
+1 -1
js/components/src/i18n/i18next-config.ts
··· 116 117 export const I18NEXT_CONFIG = { 118 lng: LOCALE, 119 - ns: ["common", "settings"], // Common should be first as it's most frequently used 120 defaultNS: "common", 121 interpolation: { 122 escapeValue: false, // React already safes from XSS
··· 116 117 export const I18NEXT_CONFIG = { 118 lng: LOCALE, 119 + ns: ["common", "settings", "chat"], // Common should be first as it's most frequently used 120 defaultNS: "common", 121 interpolation: { 122 escapeValue: false, // React already safes from XSS
+3
js/components/src/index.tsx
··· 33 34 export * from "./components/chat/chat"; 35 export * from "./components/chat/chat-box"; 36 export * from "./components/chat/system-message"; 37 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 38 export * from "./lib/system-messages"; 39 40 export * from "./components/stream-notification"; 41 export * from "./lib/stream-notifications"; 42 43 export * from "./utils/format-handle"; 44 45 export { DanmuOverlay } from "./components/danmu/danmu-overlay";
··· 33 34 export * from "./components/chat/chat"; 35 export * from "./components/chat/chat-box"; 36 + export * from "./components/chat/chat-settings"; 37 export * from "./components/chat/system-message"; 38 + export * from "./components/chat/update-stream-title-dialog"; 39 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 40 export * from "./lib/system-messages"; 41 42 export * from "./components/stream-notification"; 43 export * from "./lib/stream-notifications"; 44 45 + export * from "./utils/did"; 46 export * from "./utils/format-handle"; 47 48 export { DanmuOverlay } from "./components/danmu/danmu-overlay";
+11 -11
js/components/src/lib/theme/tokens.ts
··· 337 }, 338 339 warning: { 340 - 50: "#fffbeb", 341 - 100: "#fef3c7", 342 - 200: "#fde68a", 343 - 300: "#fcd34d", 344 - 400: "#fbbf24", 345 - 500: "#f59e0b", 346 - 600: "#d97706", 347 - 700: "#b45309", 348 - 800: "#92400e", 349 - 900: "#78350f", 350 - 950: "#451a03", 351 }, 352 353 // iOS system colors (adaptive)
··· 337 }, 338 339 warning: { 340 + 50: "#fffaf0", 341 + 100: "#ffe6c7", 342 + 200: "#ffd99e", 343 + 300: "#ffcc75", 344 + 400: "#ffb94e", 345 + 500: "#ff9e1f", 346 + 600: "#e67e00", 347 + 700: "#cc6600", 348 + 800: "#998c00", 349 + 900: "#664200", 350 + 950: "#332900", 351 }, 352 353 // iOS system colors (adaptive)
+3
js/components/src/player-store/player-state.tsx
··· 63 ingestAutoStart?: boolean; 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 66 /** Timestamp (number) when ingest started, or null if not started */ 67 ingestStarted: number | null; 68
··· 63 ingestAutoStart?: boolean; 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 66 + /** stop ingest process, again with a slight delay to allow UI to update */ 67 + stopIngest: () => void; 68 + 69 /** Timestamp (number) when ingest started, or null if not started */ 70 ingestStarted: number | null; 71
+17
js/components/src/player-store/player-store.tsx
··· 53 setIngestStarted: (timestamp: number | null) => 54 set(() => ({ ingestStarted: timestamp })), 55 56 fullscreen: false, 57 setFullscreen: (isFullscreen: boolean) => 58 set(() => ({ fullscreen: isFullscreen })),
··· 53 setIngestStarted: (timestamp: number | null) => 54 set(() => ({ ingestStarted: timestamp })), 55 56 + stopIngest: () => { 57 + set(() => ({ 58 + ingestLive: false, 59 + ingestConnectionState: "new", 60 + ingestStarted: null, 61 + })), 62 + setTimeout( 63 + () => 64 + set(() => ({ 65 + ingestLive: false, 66 + ingestConnectionState: "new", 67 + ingestStarted: null, 68 + })), 69 + 200, 70 + ); 71 + }, 72 + 73 fullscreen: false, 74 setFullscreen: (isFullscreen: boolean) => 75 set(() => ({ fullscreen: isFullscreen })),
+60 -1
js/components/src/streamplace-store/branding.tsx
··· 25 }); 26 }; 27 28 // hook to fetch broadcaster DID (unauthenticated) 29 export function useFetchBroadcasterDID() { 30 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 31 const store = getStreamplaceStoreFromContext(); 32 33 return useCallback(async () => { 34 try { ··· 140 141 // hook to get a specific branding asset by key 142 export function useBrandingAsset(key: string): BrandingAsset | undefined { 143 - return useStreamplaceStore((state) => state.branding?.[key]); 144 } 145 146 // convenience hook for main logo
··· 25 }); 26 }; 27 28 + const PropsInHeader = [ 29 + "siteTitle", 30 + "siteDescription", 31 + "primaryColor", 32 + "accentColor", 33 + "defaultStreamer", 34 + "mainLogo", 35 + "favicon", 36 + "sidebarBg", 37 + "legalLinks", 38 + ]; 39 + 40 + function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 + const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 + if (meta && meta.getAttribute("content")) { 44 + let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 + } 47 + 48 + return null; 49 + } 50 + 51 // hook to fetch broadcaster DID (unauthenticated) 52 export function useFetchBroadcasterDID() { 53 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 54 const store = getStreamplaceStoreFromContext(); 55 + 56 + // prefetch from meta records, if on web 57 + useEffect(() => { 58 + if (typeof window !== "undefined" && window.document) { 59 + try { 60 + const metaRecords = PropsInHeader.reduce( 61 + (acc, key) => { 62 + const meta = document.querySelector( 63 + `meta[name="internal-brand:${key}`, 64 + ); 65 + // hrmmmmmmmmmmmm 66 + if (meta && meta.getAttribute("content")) { 67 + let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 + } 70 + return acc; 71 + }, 72 + {} as Record<string, BrandingAsset>, 73 + ); 74 + 75 + console.log("Found meta records for broadcaster DID:", metaRecords); 76 + // filter out all non-text values, can get on second fetch? 77 + for (const key of Object.keys(metaRecords)) { 78 + if (metaRecords[key].mimeType != "text/plain") { 79 + delete metaRecords[key]; 80 + } 81 + } 82 + } catch (e) { 83 + console.warn("Failed to parse broadcaster DID from meta tags", e); 84 + } 85 + } 86 + }, []); 87 88 return useCallback(async () => { 89 try { ··· 195 196 // hook to get a specific branding asset by key 197 export function useBrandingAsset(key: string): BrandingAsset | undefined { 198 + return ( 199 + useStreamplaceStore((state) => state.branding?.[key]) || 200 + getMetaContent(key) || 201 + undefined 202 + ); 203 } 204 205 // convenience hook for main logo
+39
js/components/src/streamplace-store/streamplace-store.tsx
··· 76 setDanmuSpeed: (speed: number) => void; 77 setDanmuLaneCount: (laneCount: number) => void; 78 setDanmuMaxMessages: (maxMessages: number) => void; 79 } 80 81 export type StreamplaceStore = StoreApi<StreamplaceState>; 82 ··· 93 const DANMU_SPEED_KEY = "danmuSpeed"; 94 const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; 95 const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages"; 96 97 const store = createStore<StreamplaceState>()((set) => ({ 98 url, ··· 210 set({ danmuMaxMessages: clamped }); 211 storage 212 .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) 213 .catch(console.error); 214 }, 215 })); ··· 227 const storedDanmuMaxMessages = await storage.getItem( 228 DANMU_MAX_MESSAGES_KEY, 229 ); 230 231 let initialVolume = 1.0; 232 let initialMuted = false; ··· 236 let initialDanmuSpeed = 1; 237 let initialDanmuLaneCount = 12; 238 let initialDanmuMaxMessages = 50; 239 240 if (storedVolume) { 241 const parsedVolume = parseFloat(storedVolume); ··· 288 } 289 } 290 291 store.setState({ 292 volume: initialVolume, 293 muted: initialMuted, ··· 297 danmuSpeed: initialDanmuSpeed, 298 danmuLaneCount: initialDanmuLaneCount, 299 danmuMaxMessages: initialDanmuMaxMessages, 300 }); 301 } catch (error) { 302 console.error("Failed to load state from storage:", error); ··· 409 setDanmuMaxMessages, 410 }; 411 }; 412 413 export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";
··· 76 setDanmuSpeed: (speed: number) => void; 77 setDanmuLaneCount: (laneCount: number) => void; 78 setDanmuMaxMessages: (maxMessages: number) => void; 79 + 80 + // Chat filter settings 81 + chatFilters: Set<ChatFilterCategory>; 82 + setChatFilters: (filters: Set<ChatFilterCategory>) => void; 83 } 84 + 85 + export type ChatFilterCategory = 86 + | "place.stream.richtext.defs#discriminatory" 87 + | "place.stream.richtext.defs#sexually_explicit" 88 + | "place.stream.richtext.defs#profanity"; 89 90 export type StreamplaceStore = StoreApi<StreamplaceState>; 91 ··· 102 const DANMU_SPEED_KEY = "danmuSpeed"; 103 const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; 104 const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages"; 105 + const CHAT_FILTERS_KEY = "chatFilters"; 106 107 const store = createStore<StreamplaceState>()((set) => ({ 108 url, ··· 220 set({ danmuMaxMessages: clamped }); 221 storage 222 .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) 223 + .catch(console.error); 224 + }, 225 + 226 + // Chat filter settings - start with defaults 227 + chatFilters: new Set(), 228 + 229 + setChatFilters: (filters: Set<ChatFilterCategory>) => { 230 + set({ chatFilters: filters }); 231 + storage 232 + .setItem(CHAT_FILTERS_KEY, JSON.stringify(Array.from(filters))) 233 .catch(console.error); 234 }, 235 })); ··· 247 const storedDanmuMaxMessages = await storage.getItem( 248 DANMU_MAX_MESSAGES_KEY, 249 ); 250 + const storedChatFilters = await storage.getItem(CHAT_FILTERS_KEY); 251 252 let initialVolume = 1.0; 253 let initialMuted = false; ··· 257 let initialDanmuSpeed = 1; 258 let initialDanmuLaneCount = 12; 259 let initialDanmuMaxMessages = 50; 260 + let initialChatFilters = new Set<ChatFilterCategory>(); 261 262 if (storedVolume) { 263 const parsedVolume = parseFloat(storedVolume); ··· 310 } 311 } 312 313 + if (storedChatFilters) { 314 + try { 315 + const parsed = JSON.parse(storedChatFilters); 316 + if (Array.isArray(parsed)) { 317 + initialChatFilters = new Set(parsed); 318 + } 319 + } catch (error) { 320 + console.error("Failed to parse stored chat filters:", error); 321 + } 322 + } 323 + 324 store.setState({ 325 volume: initialVolume, 326 muted: initialMuted, ··· 330 danmuSpeed: initialDanmuSpeed, 331 danmuLaneCount: initialDanmuLaneCount, 332 danmuMaxMessages: initialDanmuMaxMessages, 333 + chatFilters: initialChatFilters, 334 }); 335 } catch (error) { 336 console.error("Failed to load state from storage:", error); ··· 443 setDanmuMaxMessages, 444 }; 445 }; 446 + 447 + // Chat filter convenience hooks 448 + export const useChatFilters = () => useStreamplaceStore((x) => x.chatFilters); 449 + export const useSetChatFilters = () => 450 + useStreamplaceStore((x) => x.setChatFilters); 451 452 export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";
+61
js/components/src/utils/did.ts
···
··· 1 + export interface DIDDocument { 2 + id: string; 3 + service?: Array<{ 4 + id: string; 5 + type?: string; 6 + serviceEndpoint?: string; 7 + }>; 8 + [key: string]: any; 9 + } 10 + 11 + export async function resolveDIDDocument(did: string): Promise<DIDDocument> { 12 + let didDocUrl: string; 13 + 14 + if (did.startsWith("did:web:")) { 15 + // For did:web, construct the URL directly 16 + const domain = did.replace("did:web:", "").replace(/:/g, "/"); 17 + didDocUrl = `https://${domain}/.well-known/did.json`; 18 + } else if (did.startsWith("did:plc:")) { 19 + // For did:plc, use plc.directory 20 + didDocUrl = `https://plc.directory/${did}`; 21 + } else { 22 + throw new Error(`Unsupported DID method: ${did}`); 23 + } 24 + 25 + const response = await fetch(didDocUrl); 26 + if (!response.ok) { 27 + throw new Error( 28 + `Failed to resolve DID document for ${did}: ${response.status}`, 29 + ); 30 + } 31 + 32 + return response.json(); 33 + } 34 + 35 + export function getPDSServiceEndpoint(didDoc: DIDDocument): string { 36 + const pdsService = didDoc.service?.find((s) => s.id === "#atproto_pds"); 37 + 38 + if (!pdsService?.serviceEndpoint) { 39 + throw new Error("No PDS service endpoint found in DID document"); 40 + } 41 + 42 + return pdsService.serviceEndpoint; 43 + } 44 + 45 + export async function getBlob( 46 + did: string, 47 + cid: string, 48 + didDoc?: DIDDocument, 49 + ): Promise<Blob> { 50 + const doc = didDoc || (await resolveDIDDocument(did)); 51 + const pdsEndpoint = getPDSServiceEndpoint(doc); 52 + 53 + const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 54 + 55 + const response = await fetch(blobUrl); 56 + if (!response.ok) { 57 + throw new Error(`Failed to fetch blob: ${response.status}`); 58 + } 59 + 60 + return response.blob(); 61 + }
+2
js/docs/_redirects
···
··· 1 + / /docs 301 2 + /docs/favicon.ico https://stream.place/favicon.ico 301
+67 -37
js/docs/astro.config.mjs
··· 1 // @ts-check 2 import starlight from "@astrojs/starlight"; 3 - import { defineConfig } from "astro/config"; 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 6 // https://astro.build/config 7 export default defineConfig({ 8 base: "/docs", 9 integrations: [ 10 starlight({ 11 title: "Streamplace Docs", ··· 29 }, 30 favicon: "/favicon.ico", 31 plugins: [ 32 starlightOpenAPI([ 33 { 34 - base: "api", 35 label: "Related XRPC API endpoints", 36 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 37 sidebar: { ··· 42 }, 43 }, 44 ]), 45 - ], 46 - sidebar: [ 47 - { label: "← Back to Streamplace", link: "/../" }, 48 - { 49 - label: "How Streamplace Works (Blog)", 50 - link: "https://blog.stream.place/", 51 - attrs: { target: "_blank" }, 52 - }, 53 - { 54 - label: "Guides", 55 - items: [ 56 { 57 - label: "Start Streaming", 58 - autogenerate: { directory: "guides/start-streaming" }, 59 }, 60 { 61 - label: "Installing Streamplace", 62 - autogenerate: { directory: "guides/installing" }, 63 }, 64 { 65 - label: "Start Contributing", 66 - autogenerate: { directory: "guides/start-contributing" }, 67 }, 68 ], 69 - }, 70 - { 71 - label: "Features", 72 - autogenerate: { directory: "features" }, 73 - }, 74 - { 75 - label: "Video Metadata", 76 - autogenerate: { directory: "video-metadata" }, 77 - }, 78 - { 79 - label: "Components", 80 - autogenerate: { directory: "components" }, 81 - }, 82 - { 83 - label: "Lexicon Reference", 84 - autogenerate: { directory: "lex-reference" }, 85 - }, 86 - ...openAPISidebarGroups, 87 ], 88 }), 89 ],
··· 1 // @ts-check 2 import starlight from "@astrojs/starlight"; 3 + import { defineConfig, passthroughImageService } from "astro/config"; 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 + import starlightSidebarSwipe from "starlight-sidebar-swipe"; 6 + import starlightSidebarTopics from "starlight-sidebar-topics"; 7 8 // https://astro.build/config 9 export default defineConfig({ 10 base: "/docs", 11 + image: { 12 + service: passthroughImageService(), 13 + }, 14 integrations: [ 15 starlight({ 16 title: "Streamplace Docs", ··· 34 }, 35 favicon: "/favicon.ico", 36 plugins: [ 37 + //starlightLinksValidator(), 38 + starlightSidebarSwipe(), 39 starlightOpenAPI([ 40 { 41 + base: "/api", 42 label: "Related XRPC API endpoints", 43 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 44 sidebar: { ··· 49 }, 50 }, 51 ]), 52 + starlightSidebarTopics( 53 + [ 54 { 55 + label: "For Streamers & Viewers", 56 + link: "/", 57 + icon: "open-book", 58 + items: [ 59 + { 60 + label: "Start Streaming", 61 + autogenerate: { directory: "guides/start-streaming" }, 62 + }, 63 + { 64 + label: "Features", 65 + autogenerate: { directory: "features" }, 66 + }, 67 + ], 68 }, 69 { 70 + label: "For Developers", 71 + link: "/developers/", 72 + icon: "seti:config", 73 + id: "developers", 74 + items: [ 75 + { 76 + label: "Start Contributing", 77 + autogenerate: { directory: "guides/start-contributing" }, 78 + }, 79 + { 80 + label: "Installing Streamplace", 81 + autogenerate: { directory: "guides/installing" }, 82 + }, 83 + { 84 + label: "Video Metadata", 85 + autogenerate: { directory: "video-metadata" }, 86 + }, 87 + { 88 + label: "Components", 89 + autogenerate: { directory: "components" }, 90 + }, 91 + { 92 + label: "Localize Streamplace", 93 + autogenerate: { directory: "guides/localizing" }, 94 + }, 95 + ], 96 }, 97 { 98 + label: "API Reference", 99 + link: "/reference/", 100 + icon: "seti:json", 101 + id: "ref", 102 + items: [ 103 + { 104 + label: "Lexicon Reference", 105 + autogenerate: { directory: "lex-reference" }, 106 + }, 107 + ...openAPISidebarGroups, 108 + ], 109 }, 110 ], 111 + { 112 + topics: { 113 + ref: ["/api", "/api/**/*"], 114 + }, 115 + }, 116 + ), 117 ], 118 }), 119 ],
+8 -2
js/docs/package.json
··· 1 { 2 "name": "streamplace-docs", 3 "type": "module", 4 - "version": "0.9.3", 5 "scripts": { 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 15 "@streamplace/app": "workspace:*", 16 "astro": "^5.6.1", 17 "sharp": "^0.32.5", 18 "starlight-openapi": "^0.17.0", 19 "starlight-openapi-rapidoc": "^0.8.1-beta", 20 "streamplace": "workspace:*" 21 - } 22 }
··· 1 { 2 "name": "streamplace-docs", 3 "type": "module", 4 + "version": "0.9.9", 5 "scripts": { 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 15 "@streamplace/app": "workspace:*", 16 "astro": "^5.6.1", 17 "sharp": "^0.32.5", 18 + "starlight-links-validator": "^0.19.2", 19 "starlight-openapi": "^0.17.0", 20 "starlight-openapi-rapidoc": "^0.8.1-beta", 21 + "starlight-sidebar-swipe": "^0.1.1", 22 "streamplace": "workspace:*" 23 + }, 24 + "devDependencies": { 25 + "starlight-sidebar-topics": "^0.6.2" 26 + }, 27 + "private": true 28 }
+60
js/docs/src/components/HelpDesk.astro
···
··· 1 + --- 2 + import { Card, CardGrid } from "@astrojs/starlight/components"; 3 + 4 + interface Props { 5 + searchPlaceholder?: string; 6 + } 7 + --- 8 + 9 + <div class="helpdesk"> 10 + 11 + <h2>How can we help?</h2> 12 + <p>Search the knowledge base, or check out topics below.</p> 13 + 14 + <CardGrid> 15 + <Card title="Getting Started" icon="rocket"> 16 + <p>New to Streamplace? Start here to set up your first stream.</p> 17 + <ul> 18 + <li><a href="/docs/guides/start-streaming/quick-start">Quick start guide</a></li> 19 + <li><a href="/docs/guides/start-streaming/obs">Stream with OBS</a></li> 20 + </ul> 21 + </Card> 22 + 23 + <Card title="Developers & Self-Hosters" icon="laptop"> 24 + <p>Building with Streamplace or running your own node?</p> 25 + <ul> 26 + <li><a href="/docs/developers">Developer documentation</a></li> 27 + </ul> 28 + </Card> 29 + </CardGrid> 30 + </div> 31 + 32 + <style> 33 + .helpdesk { 34 + margin: 0 auto; 35 + } 36 + 37 + .helpdesk-search { 38 + margin-bottom: 2rem; 39 + } 40 + 41 + .search-input { 42 + width: 100%; 43 + padding: 1rem 1.5rem; 44 + font-size: 1.125rem; 45 + border: 2px solid var(--sl-color-gray-5); 46 + border-radius: 0.5rem; 47 + background: var(--sl-color-bg); 48 + color: var(--sl-color-text); 49 + transition: border-color 0.2s; 50 + } 51 + 52 + .search-input:focus { 53 + outline: none; 54 + border-color: var(--sl-color-accent); 55 + } 56 + 57 + .helpdesk h2 { 58 + margin-bottom: 1.5rem; 59 + } 60 + </style>
+1 -2
js/docs/src/content/docs/components/custom_ui.md
··· 1 --- 2 title: Creating your own player UI 3 - description: 4 - How to set up your player UI with components from @streamplace/components. 5 --- 6 7 # Building a Custom Player UI
··· 1 --- 2 title: Creating your own player UI 3 + description: How to set up your player UI with components from @streamplace/components. 4 --- 5 6 # Building a Custom Player UI
+40
js/docs/src/content/docs/developers.mdx
···
··· 1 + --- 2 + title: Developers & Self-Hosters 3 + description: Build with Streamplace or run your own infrastructure. 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + ## Learn how to deploy, or contribute to Streamplace. 10 + 11 + <br /> 12 + 13 + <CardGrid stagger> 14 + <Card title="Building an Application" icon="laptop"> 15 + Integrate live video into your project. - [API 16 + reference](/docs/lex-reference/place-stream-defs) - [Our component 17 + library](/docs/components/custom_ui/) 18 + </Card> 19 + 20 + {" "} 21 + 22 + <Card title="Self-Hosting" icon="seti:config"> 23 + Run your own Streamplace infrastructure. - [Installation 24 + guide](/docs/guides/installing/installing-streamplace) 25 + </Card> 26 + 27 + {" "} 28 + 29 + <Card title="Contributing" icon="github"> 30 + Help improve Streamplace. - [Development 31 + setup](/docs/guides/streamplace-dev-setup) - [Video 32 + signing](/docs/video-metadata/intro/) 33 + </Card> 34 + 35 + <Card title="Support & Community" icon="information"> 36 + Get help and connect with other developers. - [GitHub 37 + issues](https://github.com/streamplace/streamplace/issues) - [Discord 38 + community](https://discord.stream.place) 39 + </Card> 40 + </CardGrid>
+3 -1
js/docs/src/content/docs/features/danmu.md
··· 3 description: Add flying bullet-style chat comments to the player, or your stream 4 --- 5 6 - :::note This feature is experimental and may change in future releases. ::: 7 8 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (弹幕, 9 "bullet curtain") is a comment style where messages fly across the video
··· 3 description: Add flying bullet-style chat comments to the player, or your stream 4 --- 5 6 + :::note 7 + This feature is experimental and may change in future releases. 8 + ::: 9 10 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (弹幕, 11 "bullet curtain") is a comment style where messages fly across the video
+27
js/docs/src/content/docs/features/embed.md
···
··· 1 + --- 2 + title: Embedding your livestream 3 + description: How to embed your livestream on your website, blog, etc. 4 + --- 5 + 6 + Streamplace provides an easy way to embed your livestream on any website or 7 + blog. 8 + 9 + You can access the embedded livestream page by putting `/embed` in the URL of 10 + your livestream. For example, if your livestream URL is 11 + `https://stream.place/iame.li`, the embed URL will be 12 + `https://stream.place/embed/iame.li`. 13 + 14 + You can use the following HTML snippet to embed your livestream: 15 + 16 + ```html 17 + <iframe 18 + src="https://stream.place/embed/your-handle" 19 + width="560" 20 + height="315" 21 + frameborder="0" 22 + allowfullscreen 23 + ></iframe> 24 + ``` 25 + 26 + Alternatively, you can use the share sheet located on your livestream page. 27 + Click the "Share" button, and you'll find the embed code ready to copy.
+52
js/docs/src/content/docs/features/multistreaming.md
···
··· 1 + --- 2 + title: Multistreaming 3 + description: Forward your Streamplace stream to other providers. 4 + --- 5 + 6 + :::note 7 + This guide isn't about setting up Streamplace as an OBS destination. See [OBS Multistreaming to Streamplace](/docs/guides/start-streaming/obs-multistreaming/) for information on that. 8 + ::: 9 + 10 + Multistreaming lets you forward your Streamplace stream to multiple platforms at the same time. Instead of streaming only to Streamplace, you can forward your stream to any platform that accepts RTMP input. 11 + 12 + ## Setting up multistream targets 13 + 14 + 1. Go to **Settings** > **Streaming** > **Multistream Targets** 15 + 2. Click **Create Multistream Target** 16 + 3. Enter the RTMP or RTMPS URL from your destination platform 17 + 4. Optionally give it a name to identify it later 18 + 5. Click **Create** 19 + 20 + ### Finding your multistream URL 21 + 22 + Different platforms will provide their own RTMP URLs. Some common examples: 23 + 24 + - **YouTube Live**: Format `rtmp://a.rtmp.youtube.com/live2/your-stream-key` 25 + - Find your stream key at https://studio.youtube.com/channel/UC/livestreaming (click the copy icon in the top right corner of the 'connect your encoder to go live' box) 26 + - **Twitch**: Format `rtmp://usw20.contribute.live-video.net/app/your-stream-key` 27 + - You can get a valid RTMPS url at https://help.twitch.tv/s/twitch-ingest-recommendation 28 + - Find your stream key at https://dashboard.twitch.tv/settings/stream (your 'primary stream key') 29 + 30 + :::note 31 + Your stream key should automatically be hidden once you confirm. Make sure you've entered it correctly! 32 + ::: 33 + 34 + ## Managing targets during a stream 35 + 36 + When you're live, you can see all your multistream targets on the Live Dashboard with their current status: 37 + 38 + - **Green (Active)**: Successfully streaming to this target 39 + - **Yellow (Pending)**: Connecting to this target 40 + - **Red (Error)**: Connection failed; check your URL and credentials 41 + - **Gray (Inactive)**: This target is disabled 42 + 43 + You can toggle any target on or off with the switch next to its name. Changes take effect immediately. 44 + 45 + ## Limits 46 + 47 + - **Maximum targets**: 100 total per account 48 + - **Maximum active targets**: 5 simultaneous streams 49 + 50 + ### Credits 51 + 52 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+83
js/docs/src/content/docs/features/webhooks.md
···
··· 1 + --- 2 + title: Discord Webhooks 3 + description: Configure Discord webhooks for livestream announcements and chat 4 + sidebar: 5 + order: 30 6 + --- 7 + 8 + Streamplace supports Discord webhooks for receiving livestream 9 + notifications and chat messages. You can create, manage, and configure webhooks 10 + to customize how events are delivered to your Discord channels. 11 + 12 + ## Webhook Events 13 + 14 + You can configure webhooks to listen for specific events. For right now, the 15 + following events are supported: 16 + 17 + - `Chat`: Triggered when a chat message is sent. 18 + - `Livestream`: Triggered when a livestream starts. 19 + 20 + ## Creating a Webhook 21 + 22 + To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 + navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 + fields are required: 25 + 26 + - Name: Webhook URL. For example, 27 + `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 + - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 + `Livestream Started`). `Livestream Started` is pre-checked by default. 30 + 31 + We'd recommend also filling out these optional fields: 32 + 33 + - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 + you can remember. 35 + - Description: A description of what this webhook is for (e.g., "Sends 36 + livestream start notifications to Discord channel"). 37 + - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 + "[Streamplace] "). Will apply to both Chat and Livestream events! 39 + - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 + live!"). Will apply to both Chat and Livestream events! 41 + - Text replacements: A list of text replacements to apply to chat messages sent 42 + by this webhook. Each replacement consists of a "from" string and a "to" 43 + string. For example, you could replace all instances of "foo" with "bar". 44 + 45 + After filling out the form, click "Create" to save your webhook. You should see 46 + it listed in the "Webhooks" section. 47 + 48 + ## Updating a Webhook 49 + 50 + To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 + navigate to the "Webhooks" section. Find the webhook you want to update and 52 + click on the "pen" icon next to it. This will open the webhook edit form, where 53 + you can modify the fields as needed. After making your changes, click "Update" 54 + to save your changes. 55 + 56 + ## Deleting a Webhook 57 + 58 + To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 + navigate to the "Webhooks" section. Find the webhook you want to delete and 60 + click on the "trash" icon next to it. A confirmation dialog will appear; click 61 + "Delete" to confirm. The webhook will be removed from the list. 62 + 63 + ## Recommendations 64 + 65 + We'd recommend: 66 + 67 + - Creating separate Discord channels for livestream notifications and chat 68 + messages to keep them organized. 69 + - If you want to have one webhook for both chat and livestream events, you can 70 + create multiple webhooks with the same URL but different event subscriptions 71 + and prefixes/suffixes/replacements. 72 + - Testing your webhook by starting a livestream or sending a chat message to 73 + ensure that notifications are being sent correctly. 74 + 75 + ## API Documentation 76 + 77 + See these endpoint pages: 78 + 79 + - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 + - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 + - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 + - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 + - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
+1 -2
js/docs/src/content/docs/guides/start-contributing/styling-quick-reference.md
··· 1 --- 2 title: ZeroCSS Quick Reference 3 - description: 4 - Quick reference for Streamplace ZeroCSS - common patterns and utilities. 5 sidebar: 6 order: 31 7 ---
··· 1 --- 2 title: ZeroCSS Quick Reference 3 + description: Quick reference for Streamplace ZeroCSS - common patterns and utilities. 4 sidebar: 5 order: 31 6 ---
-83
js/docs/src/content/docs/guides/start-streaming/discord-hooks.md
··· 1 - --- 2 - title: Discord Webhooks 3 - description: Configure Discord webhooks for livestream announcements and chat 4 - sidebar: 5 - order: 30 6 - --- 7 - 8 - Streamplace supports Discord webhook integration for receiving livestream 9 - notifications and chat messages. You can create, manage, and configure webhooks 10 - to customize how events are delivered to your Discord channels. 11 - 12 - ## Webhook Events 13 - 14 - You can configure webhooks to listen for specific events. For right now, the 15 - following events are supported: 16 - 17 - - `Chat`: Triggered when a chat message is sent. 18 - - `Livestream`: Triggered when a livestream starts. 19 - 20 - ## Creating a Webhook 21 - 22 - To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 - navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 - fields are required: 25 - 26 - - Name: Webhook URL. For example, 27 - `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 - - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 - `Livestream Started`). `Livestream Started` is pre-checked by default. 30 - 31 - We'd recommend also filling out these optional fields: 32 - 33 - - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 - you can remember. 35 - - Description: A description of what this webhook is for (e.g., "Sends 36 - livestream start notifications to Discord channel"). 37 - - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 - "[Streamplace] "). Will apply to both Chat and Livestream events! 39 - - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 - live!"). Will apply to both Chat and Livestream events! 41 - - Text replacements: A list of text replacements to apply to chat messages sent 42 - by this webhook. Each replacement consists of a "from" string and a "to" 43 - string. For example, you could replace all instances of "foo" with "bar". 44 - 45 - After filling out the form, click "Create" to save your webhook. You should see 46 - it listed in the "Webhooks" section. 47 - 48 - ## Updating a Webhook 49 - 50 - To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 - navigate to the "Webhooks" section. Find the webhook you want to update and 52 - click on the "pen" icon next to it. This will open the webhook edit form, where 53 - you can modify the fields as needed. After making your changes, click "Update" 54 - to save your changes. 55 - 56 - ## Deleting a Webhook 57 - 58 - To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 - navigate to the "Webhooks" section. Find the webhook you want to delete and 60 - click on the "trash" icon next to it. A confirmation dialog will appear; click 61 - "Delete" to confirm. The webhook will be removed from the list. 62 - 63 - ## Recommendations 64 - 65 - We'd recommend: 66 - 67 - - Creating separate Discord channels for livestream notifications and chat 68 - messages to keep them organized. 69 - - If you want to have one webhook for both chat and livestream events, you can 70 - create multiple webhooks with the same URL but different event subscriptions 71 - and prefixes/suffixes/replacements. 72 - - Testing your webhook by starting a livestream or sending a chat message to 73 - ensure that notifications are being sent correctly. 74 - 75 - ## API Documentation 76 - 77 - See these endpoint pages: 78 - 79 - - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 - - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 - - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 - - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 - - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
···
-27
js/docs/src/content/docs/guides/start-streaming/embed.md
··· 1 - --- 2 - title: Embedding your livestream 3 - description: How to embed your livestream on your website, blog, etc. 4 - --- 5 - 6 - Streamplace provides an easy way to embed your livestream on any website or 7 - blog. 8 - 9 - You can access the embedded livestream page by putting `/embed` in the URL of 10 - your livestream. For example, if your livestream URL is 11 - `https://stream.place/iame.li`, the embed URL will be 12 - `https://stream.place/embed/iame.li`. 13 - 14 - You can use the following HTML snippet to embed your livestream: 15 - 16 - ```html 17 - <iframe 18 - src="https://stream.place/embed/your-handle" 19 - width="560" 20 - height="315" 21 - frameborder="0" 22 - allowfullscreen 23 - ></iframe> 24 - ``` 25 - 26 - Alternatively, you can use the share sheet located on your livestream page. 27 - Click the "Share" button, and you'll find the embed code ready to copy.
···
+7 -1
js/docs/src/content/docs/guides/start-streaming/obs-multistreaming.md
··· 1 --- 2 - title: OBS Multistreaming with Streamplace 3 description: 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 obs-multi-rtmp plugin. 6 sidebar: 7 order: 20 8 --- 9 10 This guide explains how to configure Open Broadcaster Software (OBS) for 11 simultaneous streaming to Streamplace and other platforms using the
··· 1 --- 2 + title: OBS Multistreaming to Streamplace 3 description: 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 obs-multi-rtmp plugin. 6 sidebar: 7 order: 20 8 --- 9 + 10 + :::note 11 + This guide is not about the multistreaming feature. Check 12 + [the multistreaming guide](/docs/features/multistreaming) out for more 13 + information. 14 + ::: 15 16 This guide explains how to configure Open Broadcaster Software (OBS) for 17 simultaneous streaming to Streamplace and other platforms using the
+21 -2
js/docs/src/content/docs/guides/start-streaming/obs.md
··· 58 - Audio Encoder: 59 - For `RTMP`, choose an appropriate AAC encoder. 60 - For `WHIP`, use `ffmpeg_opus`. 61 - Video Encoder: _(Select appropriate encoder, e.g. libx264/nvenc_h264)_ 62 63 #### 2e. Suggested Video Encoder Settings 64 65 - Rate Control: `CBR` 66 - - Keyframe Interval: `1s` 67 - x264 Options: `bframes=0` 68 - - If available, there also may be a 'no bframes' checkbox which should be 69 checked 70 71 ### 3. Announce your stream 72 73 1. Once you're live, go back to the live dashboard. ··· 85 86 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 87 88 ## Best Practices 89 90 - Test your stream settings before going live ··· 96 ## Additional Resources 97 98 - [OBS Official Documentation](https://obsproject.com/docs/)
··· 58 - Audio Encoder: 59 - For `RTMP`, choose an appropriate AAC encoder. 60 - For `WHIP`, use `ffmpeg_opus`. 61 + - If you are using a server that supports the SRT protocol (e.g. 62 + multistreaming via NGINX) please check below for an example config. 63 - Video Encoder: _(Select appropriate encoder, e.g. libx264/nvenc_h264)_ 64 65 #### 2e. Suggested Video Encoder Settings 66 67 + - Video Encoder: x264/h264 (**must** be an x/h.264 encoder) 68 - Rate Control: `CBR` 69 + - Keyframe Interval: `1s` (or anything less than once every ~7s) 70 + - This is _one keyframe per second_ 71 + - In some situations (e.g. 'keyframe interval (**frames**)'), this should be 72 + set to your FPS. 73 - x264 Options: `bframes=0` 74 + - If available, there also may be a 'bframes' checkbox which should **NOT** be 75 checked 76 77 + :::caution 78 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 79 + ::: 80 + 81 ### 3. Announce your stream 82 83 1. Once you're live, go back to the live dashboard. ··· 95 96 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 97 98 + Alternatively, you can 99 + [multistream through Streamplace itself.](/docs/features/multistreaming) 100 + 101 ## Best Practices 102 103 - Test your stream settings before going live ··· 109 ## Additional Resources 110 111 - [OBS Official Documentation](https://obsproject.com/docs/) 112 + 113 + ### Example Settings 114 + 115 + ![SRT settings in OBS.](srt.png "OBS SRT Settings") 116 + 117 + > Multistreaming via a server that supports the SRT protocol
+73
js/docs/src/content/docs/guides/start-streaming/quick-start.md
···
··· 1 + --- 2 + title: Quick Start 3 + description: Get up and streaming on Streamplace quickly. 4 + sidebar: 5 + order: 1 6 + --- 7 + 8 + This guide gets you from zero to streaming. If you get stuck, check out the full [OBS setup guide](/docs/guides/start-streaming/obs). 9 + 10 + :::tip 11 + You will want to check out our [community guidelines](https://blog.stream.place/3mcqwibo4ks2w) first for guidance on what you can and cannot do on Streamplace. 12 + ::: 13 + 14 + ## So, what is Streamplace? 15 + 16 + Streamplace is a video streaming service built on top of the AT Protocol (Authenticated Transfer Protocol), the same protocol Bluesky is built on. 17 + 18 + ## Step 1: Create your account 19 + 20 + 1. Go to [stream.place](https://stream.place) 21 + 2. Click "Sign in" in the top right. 22 + 3. Use your Atmosphere credentials to log in (ex. your Bluesky handle) 23 + - You'll need to use your actual password here - we're using OAuth so you enter your password on your PDS. We do not receive your password at all. 24 + 4. You're done! Your stream profile is live at `stream.place/your-handle` 25 + 26 + ## Step 2: Get your stream key 27 + 28 + 1. Click **Live Dashboard** (or go to [stream.place/dashboard](https://stream.place/dashboard)) 29 + 2. Click **Stream from OBS** 30 + 3. Click **Generate Stream Key** 31 + 4. Your key is copied to clipboard automatically 32 + 33 + Keep this key private. It's like a password, but for your stream. 34 + 35 + ## Step 3: Configure OBS 36 + 37 + Open OBS and go to **Settings → Stream**: 38 + 39 + - **Service**: `Custom...` 40 + - **Server**: `rtmps://stream.place:1935/live` 41 + - **Stream Key**: Paste what you copied in Step 2 42 + 43 + Then go to **Settings → Output → Streaming**: 44 + 45 + - **Video Encoder**: `libx264` (or `NVIDIA NVENC H.264` if you have an NVIDIA GPU) 46 + - **Rate Control**: `CBR` 47 + - **Bitrate**: `6000` Kbps (adjust down if you drop frames) 48 + - **Keyframe Interval**: `1` 49 + - **x264 Options**: `bframes=0`. If there's a 'bframes' option, you'll want to have that at '0' or unchecked. 50 + 51 + :::caution 52 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 53 + ::: 54 + 55 + ## Step 4: Go live 56 + 57 + 1. In OBS, click **Start Streaming** 58 + 2. Go back to the Live Dashboard at stream.place 59 + 3. Fill in your stream title and optionally pick a thumbnail8 60 + 4. If needed, turn on content warnings. ("Metadata" tab in Stream Settings) 61 + 5. Click **Announce Livestream** 62 + 6. Your stream is now live and visible to the world! 63 + 64 + ## Next steps 65 + 66 + - **Customize your chat**: Change your name color in Settings > Account 67 + - **Stream to other platforms too**: Set your Twitch/YouTube URLs in Settings > Multistream Targets to push your stream there automatically. See the [Multistreaming guide](/docs/features/multistreaming) for more information 68 + - **Improve stream quality**: See the [OBS guide](/docs/guides/start-streaming/obs) for encoder settings and troubleshooting 69 + - **Join the Discord!**: If you need any help, or just want to chat, check out our discord at https://discord.stream.place. 70 + 71 + ### Credits 72 + 73 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s excellent [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
js/docs/src/content/docs/guides/start-streaming/srt.png

This is a binary file and will not be displayed.

+2 -32
js/docs/src/content/docs/index.mdx
··· 2 title: Welcome to Streamplace! 3 description: Begin your development journey with the Streamplace documentation. 4 template: doc 5 - hero: 6 - tagline: Solve live video for your project with Streamplace. 7 - image: 8 - file: ../../assets/cube.png 9 - alt: Streamplace logo. A pink 3d box viewed from a top corner. 10 - actions: 11 - - text: Get Started 12 - link: /docs/guides/start-streaming/obs 13 - icon: right-arrow 14 - - text: Visit Streamplace 15 - link: / 16 - icon: external 17 - variant: minimal 18 --- 19 20 - import { Card, CardGrid } from "@astrojs/starlight/components"; 21 - 22 - ## Next Steps 23 24 - <CardGrid> 25 - <Card title="Read the Docs" icon="open-book"> 26 - Learn how to start streaming with 27 - [Streamplace](/docs/guides/start-streaming/obs). 28 - </Card> 29 - <Card title="Install Streamplace" icon="download"> 30 - [Run your own Streamplace 31 - node](/docs/guides/installing/installing-streamplace). 32 - </Card> 33 - <Card title="API Reference" icon="document"> 34 - Explore the [Lexicon API reference](/docs/lex-reference/place-stream-defs). 35 - </Card> 36 - <Card title="Developer Setup" icon="setting"> 37 - Set up your [development environment](/docs/guides/streamplace-dev-setup). 38 - </Card> 39 - </CardGrid>
··· 2 title: Welcome to Streamplace! 3 description: Begin your development journey with the Streamplace documentation. 4 template: doc 5 --- 6 7 + import HelpDesk from "../../components/HelpDesk.astro"; 8 9 + <HelpDesk />
+2 -1
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getblob.md
··· 28 - **Description:** Raw blob data with appropriate content-type 29 - **Schema:** 30 31 - _Schema not defined._ **Possible Errors:** 32 33 - `BrandingNotFound`: The requested branding asset does not exist 34
··· 28 - **Description:** Raw blob data with appropriate content-type 29 - **Schema:** 30 31 + _Schema not defined._ 32 + **Possible Errors:** 33 34 - `BrandingNotFound`: The requested branding asset does not exist 35
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-origin.md
··· 13 14 **Type:** `record` 15 16 - Record indicating a livestream is published and available for replication at a 17 - given address. By convention, the record key is streamer::server 18 19 **Record Key:** `any` 20
··· 13 14 **Type:** `record` 15 16 + Record indicating a livestream is published and available for replication at a given address. By convention, the record key is streamer::server 17 18 **Record Key:** `any` 19
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-syndication.md
··· 13 14 **Type:** `record` 15 16 - Record created by a Streamplace broadcaster to indicate that they will be 17 - replicating a livestream. NYI 18 19 **Record Key:** `tid` 20
··· 13 14 **Type:** `record` 15 16 + Record created by a Streamplace broadcaster to indicate that they will be replicating a livestream. NYI 17 18 **Record Key:** `tid` 19
+58 -2
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 20 | `uri` | `string` | ✅ | | Format: `at-uri` | 21 | `cid` | `string` | ✅ | | Format: `cid` | 22 | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | | 23 - | `record` | `unknown` | ✅ | | | 24 | `indexedAt` | `string` | ✅ | | Format: `datetime` | 25 | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 26 | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | ❌ | | | ··· 28 29 --- 30 31 ## Lexicon Source 32 33 ```json ··· 52 "ref": "app.bsky.actor.defs#profileViewBasic" 53 }, 54 "record": { 55 - "type": "unknown" 56 }, 57 "indexedAt": { 58 "type": "string", ··· 69 "deleted": { 70 "type": "boolean", 71 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 72 } 73 } 74 }
··· 20 | `uri` | `string` | ✅ | | Format: `at-uri` | 21 | `cid` | `string` | ✅ | | Format: `cid` | 22 | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | | 23 + | `record` | Union of:<br/>&nbsp;&nbsp;[`#messageRecordView`](#messagerecordview) | ✅ | | | 24 | `indexedAt` | `string` | ✅ | | Format: `datetime` | 25 | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 26 | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | ❌ | | | ··· 28 29 --- 30 31 + <a name="messagerecordview"></a> 32 + 33 + ### `messageRecordView` 34 + 35 + **Type:** `object` 36 + 37 + The content of a chat message. 38 + 39 + **Properties:** 40 + 41 + | Name | Type | Req'd | Description | Constraints | 42 + | ----------- | ------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------- | --------------------------------------- | 43 + | `text` | `string` | ✅ | The primary message content. May be an empty string, if there are embeds. | Max Length: 3000<br/>Max Graphemes: 300 | 44 + | `createdAt` | `string` | ✅ | Client-declared timestamp when this message was originally created. | Format: `datetime` | 45 + | `facets` | Array of [`place.stream.richtext.defs#facetView`](/lex-reference/place-stream-richtext-defs#facetview) | ❌ | Annotations of text (mentions, URLs, etc) | | 46 + | `streamer` | `string` | ✅ | The DID of the streamer whose chat this is. | Format: `did` | 47 + | `reply` | [`place.stream.chat.message#replyRef`](/lex-reference/place-stream-chat-message#replyref) | ❌ | | | 48 + 49 + --- 50 + 51 ## Lexicon Source 52 53 ```json ··· 72 "ref": "app.bsky.actor.defs#profileViewBasic" 73 }, 74 "record": { 75 + "type": "union", 76 + "refs": ["#messageRecordView"] 77 }, 78 "indexedAt": { 79 "type": "string", ··· 90 "deleted": { 91 "type": "boolean", 92 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 93 + } 94 + } 95 + }, 96 + "messageRecordView": { 97 + "type": "object", 98 + "description": "The content of a chat message.", 99 + "required": ["text", "createdAt", "streamer"], 100 + "properties": { 101 + "text": { 102 + "type": "string", 103 + "maxLength": 3000, 104 + "maxGraphemes": 300, 105 + "description": "The primary message content. May be an empty string, if there are embeds." 106 + }, 107 + "createdAt": { 108 + "type": "string", 109 + "format": "datetime", 110 + "description": "Client-declared timestamp when this message was originally created." 111 + }, 112 + "facets": { 113 + "type": "array", 114 + "description": "Annotations of text (mentions, URLs, etc)", 115 + "items": { 116 + "type": "ref", 117 + "ref": "place.stream.richtext.defs#facetView" 118 + } 119 + }, 120 + "streamer": { 121 + "type": "string", 122 + "format": "did", 123 + "description": "The DID of the streamer whose chat this is." 124 + }, 125 + "reply": { 126 + "type": "ref", 127 + "ref": "place.stream.chat.message#replyRef" 128 } 129 } 130 }
+2 -1
js/docs/src/content/docs/lex-reference/live/place-stream-live-getprofilecard.md
··· 26 - **Encoding:** `*/*` 27 - **Schema:** 28 29 - _Schema not defined._ **Possible Errors:** 30 31 - `RepoNotFound` 32
··· 26 - **Encoding:** `*/*` 27 - **Schema:** 28 29 + _Schema not defined._ 30 + **Possible Errors:** 31 32 - `RepoNotFound` 33
+1 -2
js/docs/src/content/docs/lex-reference/live/place-stream-live-searchactorstypeahead.md
··· 13 14 **Type:** `query` 15 16 - Find actor suggestions for a prefix search term. Expected use is for 17 - auto-completion during text field entry. 18 19 **Parameters:** 20
··· 13 14 **Type:** `query` 15 16 + Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. 17 18 **Parameters:** 19
-133
js/docs/src/content/docs/lex-reference/livestream/place-stream-livestream-update.md
··· 1 - --- 2 - title: place.stream.livestream.update 3 - description: Reference for the place.stream.livestream.update lexicon 4 - --- 5 - 6 - **Lexicon Version:** 1 7 - 8 - ## Definitions 9 - 10 - <a name="main"></a> 11 - 12 - ### `main` 13 - 14 - **Type:** `procedure` 15 - 16 - Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 19 - 20 - **Parameters:** _(None defined)_ 21 - 22 - **Input:** 23 - 24 - - **Encoding:** `application/json` 25 - - **Schema:** 26 - 27 - **Schema Type:** `object` 28 - 29 - | Name | Type | Req'd | Description | Constraints | 30 - | --------------- | -------- | ----- | ---------------------------------------------- | --------------------------------------- | 31 - | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 32 - | `livestreamUri` | `string` | ✅ | The AT-URI of the livestream record to update. | Format: `at-uri` | 33 - | `title` | `string` | ❌ | New title for the livestream. | Max Length: 1400<br/>Max Graphemes: 140 | 34 - 35 - **Output:** 36 - 37 - - **Encoding:** `application/json` 38 - - **Schema:** 39 - 40 - **Schema Type:** `object` 41 - 42 - | Name | Type | Req'd | Description | Constraints | 43 - | ----- | -------- | ----- | -------------------------------------------- | ---------------- | 44 - | `uri` | `string` | ✅ | The AT-URI of the updated livestream record. | Format: `at-uri` | 45 - | `cid` | `string` | ✅ | The CID of the updated livestream record. | Format: `cid` | 46 - 47 - **Possible Errors:** 48 - 49 - - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 54 - - `RecordNotFound`: The specified livestream record does not exist. 55 - 56 - --- 57 - 58 - ## Lexicon Source 59 - 60 - ```json 61 - { 62 - "lexicon": 1, 63 - "id": "place.stream.livestream.update", 64 - "defs": { 65 - "main": { 66 - "type": "procedure", 67 - "description": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.", 68 - "input": { 69 - "encoding": "application/json", 70 - "schema": { 71 - "type": "object", 72 - "required": ["streamer", "livestreamUri"], 73 - "properties": { 74 - "streamer": { 75 - "type": "string", 76 - "format": "did", 77 - "description": "The DID of the streamer." 78 - }, 79 - "livestreamUri": { 80 - "type": "string", 81 - "format": "at-uri", 82 - "description": "The AT-URI of the livestream record to update." 83 - }, 84 - "title": { 85 - "type": "string", 86 - "maxLength": 1400, 87 - "maxGraphemes": 140, 88 - "description": "New title for the livestream." 89 - } 90 - } 91 - } 92 - }, 93 - "output": { 94 - "encoding": "application/json", 95 - "schema": { 96 - "type": "object", 97 - "required": ["uri", "cid"], 98 - "properties": { 99 - "uri": { 100 - "type": "string", 101 - "format": "at-uri", 102 - "description": "The AT-URI of the updated livestream record." 103 - }, 104 - "cid": { 105 - "type": "string", 106 - "format": "cid", 107 - "description": "The CID of the updated livestream record." 108 - } 109 - } 110 - } 111 - }, 112 - "errors": [ 113 - { 114 - "name": "Unauthorized", 115 - "description": "The request lacks valid authentication credentials." 116 - }, 117 - { 118 - "name": "Forbidden", 119 - "description": "The caller does not have permission to update livestream metadata for this streamer." 120 - }, 121 - { 122 - "name": "SessionNotFound", 123 - "description": "The streamer's OAuth session could not be found or is invalid." 124 - }, 125 - { 126 - "name": "RecordNotFound", 127 - "description": "The specified livestream record does not exist." 128 - } 129 - ] 130 - } 131 - } 132 - } 133 - ```
···
+1 -2
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
··· 13 14 **Type:** `record` 15 16 - Default metadata record for livestream including content warnings, rights, and 17 - distribution policy 18 19 **Record Key:** `literal:self` 20
··· 13 14 **Type:** `record` 15 16 + Default metadata record for livestream including content warnings, rights, and distribution policy 17 18 **Record Key:** `literal:self` 19
+8 -19
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
··· 33 34 **Type:** `token` 35 36 - All rights reserved to the creator — others cannot use, modify, or share without 37 - explicit authorization. 38 39 --- 40 ··· 44 45 **Type:** `token` 46 47 - Public domain dedication. You waive all copyright and related rights where 48 - possible. Others may copy, modify, distribute, or perform your work for any 49 - purpose without attribution. 50 51 --- 52 ··· 56 57 **Type:** `token` 58 59 - Attribution required. Others may copy, distribute, remix, and build upon your 60 - work, even commercially, if they credit you. 61 62 --- 63 ··· 67 68 **Type:** `token` 69 70 - Attribution + share-alike. Others may adapt and build upon your work, even 71 - commercially, if they credit you and license their new creations under identical 72 - terms. 73 74 --- 75 ··· 79 80 **Type:** `token` 81 82 - Attribution + non-commercial. Others may adapt and build upon your work for 83 - non-commercial purposes only, and must credit you. 84 85 --- 86 ··· 90 91 **Type:** `token` 92 93 - Attribution + non-commercial + share-alike. Others may adapt and build upon your 94 - work for non-commercial purposes only, must credit you, and must license their 95 - new creations under identical terms. 96 97 --- 98 ··· 102 103 **Type:** `token` 104 105 - Attribution + no derivatives. Others may reuse your work, even commercially, but 106 - it must remain unchanged and you must be credited. 107 108 --- 109 ··· 113 114 **Type:** `token` 115 116 - Attribution + non-commercial + no derivatives. Others may download and share 117 - your work with credit, but cannot change it or use it commercially. 118 119 --- 120
··· 33 34 **Type:** `token` 35 36 + All rights reserved to the creator — others cannot use, modify, or share without explicit authorization. 37 38 --- 39 ··· 43 44 **Type:** `token` 45 46 + Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution. 47 48 --- 49 ··· 53 54 **Type:** `token` 55 56 + Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you. 57 58 --- 59 ··· 63 64 **Type:** `token` 65 66 + Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms. 67 68 --- 69 ··· 73 74 **Type:** `token` 75 76 + Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you. 77 78 --- 79 ··· 83 84 **Type:** `token` 85 86 + Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms. 87 88 --- 89 ··· 93 94 **Type:** `token` 95 96 + Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited. 97 98 --- 99 ··· 103 104 **Type:** `token` 105 106 + Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially. 107 108 --- 109
+8 -18
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
··· 29 30 **Type:** `token` 31 32 - The content could be perceived as offensive due to the discussion or display of 33 - death. 34 35 --- 36 ··· 40 41 **Type:** `token` 42 43 - The content contains a portrayal of the use or abuse of mind altering 44 - substances. 45 46 --- 47 ··· 51 52 **Type:** `token` 53 54 - The content contains violent actions of a fantasy nature, involving human or 55 - non-human characters in situations easily distinguishable from real life. 56 57 --- 58 ··· 62 63 **Type:** `token` 64 65 - The content contains flashing lights that could be harmful to viewers with 66 - seizure disorders such as photosensitive epilepsy. 67 68 --- 69 ··· 93 94 **Type:** `token` 95 96 - The content contains information that can be used to identify a particular 97 - individual, such as a name, phone number, email address, physical address, or IP 98 - address. 99 100 --- 101 ··· 105 106 **Type:** `token` 107 108 - The content could be perceived as offensive due to the discussion or display of 109 - sexuality. 110 111 --- 112 ··· 116 117 **Type:** `token` 118 119 - The content could be perceived as distressing due to the discussion or display 120 - of suffering or triggering topics, including suicide, eating disorders or self 121 - harm. 122 123 --- 124 ··· 128 129 **Type:** `token` 130 131 - The content could be perceived as offensive due to the discussion or display of 132 - violence. 133 134 --- 135
··· 29 30 **Type:** `token` 31 32 + The content could be perceived as offensive due to the discussion or display of death. 33 34 --- 35 ··· 39 40 **Type:** `token` 41 42 + The content contains a portrayal of the use or abuse of mind altering substances. 43 44 --- 45 ··· 49 50 **Type:** `token` 51 52 + The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life. 53 54 --- 55 ··· 59 60 **Type:** `token` 61 62 + The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy. 63 64 --- 65 ··· 89 90 **Type:** `token` 91 92 + The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address. 93 94 --- 95 ··· 99 100 **Type:** `token` 101 102 + The content could be perceived as offensive due to the discussion or display of sexuality. 103 104 --- 105 ··· 109 110 **Type:** `token` 111 112 + The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm. 113 114 --- 115 ··· 119 120 **Type:** `token` 121 122 + The content could be perceived as offensive due to the discussion or display of violence. 123 124 --- 125
+3 -6
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createblock.md
··· 13 14 **Type:** `procedure` 15 16 - Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates 17 - an app.bsky.graph.block record in the streamer's repository. 18 19 **Parameters:** _(None defined)_ 20 ··· 46 **Possible Errors:** 47 48 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to create blocks for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 53 54 --- 55
··· 13 14 **Type:** `procedure` 15 16 + Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 45 **Possible Errors:** 46 47 - `Unauthorized`: The request lacks valid authentication credentials. 48 + - `Forbidden`: The caller does not have permission to create blocks for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 50 51 --- 52
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-creategate.md
··· 13 14 **Type:** `procedure` 15 16 - Create a gate (hide message) on behalf of a streamer. Requires 'hide' 17 - permission. Creates a place.stream.chat.gate record in the streamer's 18 - repository. 19 20 **Parameters:** _(None defined)_ 21 ··· 46 **Possible Errors:** 47 48 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to hide messages for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 53 54 --- 55
··· 13 14 **Type:** `procedure` 15 16 + Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 44 **Possible Errors:** 45 46 - `Unauthorized`: The request lacks valid authentication credentials. 47 + - `Forbidden`: The caller does not have permission to hide messages for this streamer. 48 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 49 50 --- 51
+5 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deleteblock.md
··· 13 14 **Type:** `procedure` 15 16 - Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. 17 - Deletes an app.bsky.graph.block record from the streamer's repository. 18 19 **Parameters:** _(None defined)_ 20 ··· 37 38 **Schema Type:** `object` 39 40 - _(No properties defined)_ **Possible Errors:** 41 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 - - `Forbidden`: The caller does not have permission to delete blocks for this 44 - streamer. 45 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 46 - invalid. 47 48 --- 49
··· 13 14 **Type:** `procedure` 15 16 + Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 36 37 **Schema Type:** `object` 38 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 + - `Forbidden`: The caller does not have permission to delete blocks for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 45 46 --- 47
+5 -8
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletegate.md
··· 13 14 **Type:** `procedure` 15 16 - Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' 17 - permission. Deletes a place.stream.chat.gate record from the streamer's 18 - repository. 19 20 **Parameters:** _(None defined)_ 21 ··· 38 39 **Schema Type:** `object` 40 41 - _(No properties defined)_ **Possible Errors:** 42 43 - `Unauthorized`: The request lacks valid authentication credentials. 44 - - `Forbidden`: The caller does not have permission to unhide messages for this 45 - streamer. 46 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 47 - invalid. 48 49 --- 50
··· 13 14 **Type:** `procedure` 15 16 + Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 36 37 **Schema Type:** `object` 38 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 + - `Forbidden`: The caller does not have permission to unhide messages for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 45 46 --- 47
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-updatelivestream.md
··· 13 14 **Type:** `procedure` 15 16 - Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 19 20 **Parameters:** _(None defined)_ 21 ··· 47 **Possible Errors:** 48 49 - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 54 - `RecordNotFound`: The specified livestream record does not exist. 55 56 ---
··· 13 14 **Type:** `procedure` 15 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository. 17 18 **Parameters:** _(None defined)_ 19 ··· 45 **Possible Errors:** 46 47 - `Unauthorized`: The request lacks valid authentication credentials. 48 + - `Forbidden`: The caller does not have permission to update livestream metadata for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 50 - `RecordNotFound`: The specified livestream record does not exist. 51 52 ---
-77
js/docs/src/content/docs/lex-reference/multistream/com-atproto-repo-deletetarget.md
··· 1 - --- 2 - title: com.atproto.repo.deleteTarget 3 - description: Reference for the com.atproto.repo.deleteTarget lexicon 4 - --- 5 - 6 - **Lexicon Version:** 1 7 - 8 - ## Definitions 9 - 10 - <a name="main"></a> 11 - 12 - ### `main` 13 - 14 - **Type:** `procedure` 15 - 16 - Delete a target for rebroadcasting a Streamplace stream. 17 - 18 - **Parameters:** _(None defined)_ 19 - 20 - **Input:** 21 - 22 - - **Encoding:** `application/json` 23 - - **Schema:** 24 - 25 - **Schema Type:** `object` 26 - 27 - | Name | Type | Req'd | Description | Constraints | 28 - | ------ | -------- | ----- | --------------------------------------- | -------------------- | 29 - | `rkey` | `string` | ✅ | The Record Key of the target to delete. | Format: `record-key` | 30 - 31 - **Output:** 32 - 33 - - **Encoding:** `application/json` 34 - - **Schema:** 35 - 36 - **Schema Type:** `object` 37 - 38 - _(No properties defined)_ 39 - 40 - --- 41 - 42 - ## Lexicon Source 43 - 44 - ```json 45 - { 46 - "lexicon": 1, 47 - "id": "com.atproto.repo.deleteTarget", 48 - "defs": { 49 - "main": { 50 - "type": "procedure", 51 - "description": "Delete a target for rebroadcasting a Streamplace stream.", 52 - "input": { 53 - "encoding": "application/json", 54 - "schema": { 55 - "type": "object", 56 - "required": ["rkey"], 57 - "properties": { 58 - "rkey": { 59 - "type": "string", 60 - "format": "record-key", 61 - "description": "The Record Key of the target to delete." 62 - } 63 - } 64 - } 65 - }, 66 - "output": { 67 - "encoding": "application/json", 68 - "schema": { 69 - "type": "object", 70 - "properties": {} 71 - } 72 - }, 73 - "errors": [] 74 - } 75 - } 76 - } 77 - ```
···
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-createtarget.md
··· 33 - **Encoding:** `application/json` 34 - **Schema:** 35 36 - **Schema Type:** 37 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 39 **Possible Errors:** 40
··· 33 - **Encoding:** `application/json` 34 - **Schema:** 35 36 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 37 38 **Possible Errors:** 39
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-puttarget.md
··· 34 - **Encoding:** `application/json` 35 - **Schema:** 36 37 - **Schema Type:** 38 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 39 40 **Possible Errors:** 41
··· 34 - **Encoding:** `application/json` 35 - **Schema:** 36 37 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 39 **Possible Errors:** 40
+74
js/docs/src/content/docs/lex-reference/openapi.json
··· 1956 ] 1957 } 1958 }, 1959 "/xrpc/com.atproto.sync.listRepos": { 1960 "get": { 1961 "summary": "Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.",
··· 1956 ] 1957 } 1958 }, 1959 + "/xrpc/com.atproto.sync.getRepo": { 1960 + "get": { 1961 + "summary": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 1962 + "operationId": "com.atproto.sync.getRepo", 1963 + "tags": ["com.atproto.sync"], 1964 + "responses": { 1965 + "200": { 1966 + "description": "Success", 1967 + "content": { 1968 + "application/vnd.ipld.car": { 1969 + "schema": {} 1970 + } 1971 + } 1972 + }, 1973 + "400": { 1974 + "description": "Bad Request", 1975 + "content": { 1976 + "application/json": { 1977 + "schema": { 1978 + "type": "object", 1979 + "required": ["error", "message"], 1980 + "properties": { 1981 + "error": { 1982 + "type": "string", 1983 + "oneOf": [ 1984 + { 1985 + "const": "RepoNotFound" 1986 + }, 1987 + { 1988 + "const": "RepoTakendown" 1989 + }, 1990 + { 1991 + "const": "RepoSuspended" 1992 + }, 1993 + { 1994 + "const": "RepoDeactivated" 1995 + } 1996 + ] 1997 + }, 1998 + "message": { 1999 + "type": "string" 2000 + } 2001 + } 2002 + } 2003 + } 2004 + } 2005 + } 2006 + }, 2007 + "parameters": [ 2008 + { 2009 + "name": "did", 2010 + "in": "query", 2011 + "required": true, 2012 + "description": "The DID of the repo.", 2013 + "schema": { 2014 + "type": "string", 2015 + "description": "The DID of the repo.", 2016 + "format": "did" 2017 + } 2018 + }, 2019 + { 2020 + "name": "since", 2021 + "in": "query", 2022 + "required": false, 2023 + "description": "The revision ('rev') of the repo to create a diff from.", 2024 + "schema": { 2025 + "type": "string", 2026 + "description": "The revision ('rev') of the repo to create a diff from.", 2027 + "format": "tid" 2028 + } 2029 + } 2030 + ] 2031 + } 2032 + }, 2033 "/xrpc/com.atproto.sync.listRepos": { 2034 "get": { 2035 "summary": "Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.",
+138
js/docs/src/content/docs/lex-reference/richtext/place-stream-richtext-defs.md
···
··· 1 + --- 2 + title: place.stream.richtext.defs 3 + description: Reference for the place.stream.richtext.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="facetview"></a> 11 + 12 + ### `facetView` 13 + 14 + **Type:** `object` 15 + 16 + Annotation of a sub-string within rich text. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 22 + | `index` | [`app.bsky.richtext.facet#byteSlice`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#byteSlice) | ✅ | | | 23 + | `features` | Array of Union of:<br/>&nbsp;&nbsp;[`app.bsky.richtext.facet#mention`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#mention)<br/>&nbsp;&nbsp;[`app.bsky.richtext.facet#link`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#link)<br/>&nbsp;&nbsp;[`#censor`](#censor) | ✅ | | | 24 + 25 + --- 26 + 27 + <a name="censor"></a> 28 + 29 + ### `censor` 30 + 31 + **Type:** `object` 32 + 33 + Indicates that the text in the given index has been censored. 34 + 35 + **Properties:** 36 + 37 + | Name | Type | Req'd | Description | Constraints | 38 + | ------------ | ----------------- | ----- | ------------------------------ | ----------- | 39 + | `reason` | `string` | ❌ | | | 40 + | `categories` | Array of `string` | ❌ | Categories of censored content | | 41 + 42 + --- 43 + 44 + <a name="discriminatory"></a> 45 + 46 + ### `discriminatory` 47 + 48 + **Type:** `token` 49 + 50 + Indicates that the text has been censored due to discriminatory content. 51 + 52 + --- 53 + 54 + <a name="sexuallyexplicit"></a> 55 + 56 + ### `sexually_explicit` 57 + 58 + **Type:** `token` 59 + 60 + Indicates that the text has been censored due to sexually explicit content. 61 + 62 + --- 63 + 64 + <a name="profanity"></a> 65 + 66 + ### `profanity` 67 + 68 + **Type:** `token` 69 + 70 + Indicates that the text has been censored due to profanity. 71 + 72 + --- 73 + 74 + ## Lexicon Source 75 + 76 + ```json 77 + { 78 + "lexicon": 1, 79 + "id": "place.stream.richtext.defs", 80 + "defs": { 81 + "facetView": { 82 + "type": "object", 83 + "description": "Annotation of a sub-string within rich text.", 84 + "required": ["index", "features"], 85 + "properties": { 86 + "index": { 87 + "type": "ref", 88 + "ref": "app.bsky.richtext.facet#byteSlice" 89 + }, 90 + "features": { 91 + "type": "array", 92 + "items": { 93 + "type": "union", 94 + "refs": [ 95 + "app.bsky.richtext.facet#mention", 96 + "app.bsky.richtext.facet#link", 97 + "#censor" 98 + ] 99 + } 100 + } 101 + } 102 + }, 103 + "censor": { 104 + "type": "object", 105 + "description": "Indicates that the text in the given index has been censored.", 106 + "properties": { 107 + "reason": { 108 + "type": "string" 109 + }, 110 + "categories": { 111 + "type": "array", 112 + "items": { 113 + "type": "string", 114 + "knownValues": [ 115 + "place.stream.richtext.defs#discriminatory", 116 + "place.stream.richtext.defs#sexually_explicit", 117 + "place.stream.richtext.defs#profanity" 118 + ] 119 + }, 120 + "description": "Categories of censored content" 121 + } 122 + } 123 + }, 124 + "discriminatory": { 125 + "type": "token", 126 + "description": "Indicates that the text has been censored due to discriminatory content." 127 + }, 128 + "sexually_explicit": { 129 + "type": "token", 130 + "description": "Indicates that the text has been censored due to sexually explicit content." 131 + }, 132 + "profanity": { 133 + "type": "token", 134 + "description": "Indicates that the text has been censored due to profanity." 135 + } 136 + } 137 + } 138 + ```
+9
js/docs/src/content/docs/reference.mdx
···
··· 1 + --- 2 + title: API Reference 3 + description: Our XRPC and OpenAPI Reference documentation 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + Here contains our XRPC and OpenAPI Reference documentation.
+22
js/docs/src/pages/back-to-home.astro
···
··· 1 + --- 2 + import "../styles/custom-font-face.css" 3 + import "@fontsource/atkinson-hyperlegible-next/400.css" 4 + import "@fontsource/atkinson-hyperlegible-next/600.css" 5 + --- 6 + <style> 7 + * { 8 + font-family: "Atkinson Hyperlegible Next"; 9 + } 10 + </style> 11 + 12 + <script is:inline> 13 + let hostname = window.location.host 14 + const redirectUrl = hostname.endsWith('.stream.place') 15 + ? 'https://stream.place' 16 + : '/'; 17 + window.location.href = redirectUrl; 18 + </script> 19 + <body style="display: flex; color: white; justify-content: center; align-items: center; height: 100vh; background-color: #181818; flex: 1; flex-direction: column;"> 20 + <h1>Redirecting to Streamplace...</h1> 21 + <a href="https://stream.place" style="color: lightskyblue">Click here if you are not redirected automatically</a> 22 + </body>
+1 -1
lerna.json
··· 1 { 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.9.3", 4 "npmClient": "pnpm" 5 }
··· 1 { 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 + "version": "0.9.9", 4 "npmClient": "pnpm" 5 }
+35
lexicons/com/atproto/sync/getRepo.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.sync.getRepo", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["did"], 11 + "properties": { 12 + "did": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "The DID of the repo." 16 + }, 17 + "since": { 18 + "type": "string", 19 + "format": "tid", 20 + "description": "The revision ('rev') of the repo to create a diff from." 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/vnd.ipld.car" 26 + }, 27 + "errors": [ 28 + { "name": "RepoNotFound" }, 29 + { "name": "RepoTakendown" }, 30 + { "name": "RepoSuspended" }, 31 + { "name": "RepoDeactivated" } 32 + ] 33 + } 34 + } 35 + }
+39 -1
lexicons/place/stream/chat/defs.json
··· 12 "type": "ref", 13 "ref": "app.bsky.actor.defs#profileViewBasic" 14 }, 15 - "record": { "type": "unknown" }, 16 "indexedAt": { "type": "string", "format": "datetime" }, 17 "chatProfile": { 18 "type": "ref", ··· 25 "deleted": { 26 "type": "boolean", 27 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 28 } 29 } 30 }
··· 12 "type": "ref", 13 "ref": "app.bsky.actor.defs#profileViewBasic" 14 }, 15 + "record": { 16 + "type": "union", 17 + "refs": ["#messageRecordView"] 18 + }, 19 "indexedAt": { "type": "string", "format": "datetime" }, 20 "chatProfile": { 21 "type": "ref", ··· 28 "deleted": { 29 "type": "boolean", 30 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 31 + } 32 + } 33 + }, 34 + "messageRecordView": { 35 + "type": "object", 36 + "description": "The content of a chat message.", 37 + "required": ["text", "createdAt", "streamer"], 38 + "properties": { 39 + "text": { 40 + "type": "string", 41 + "maxLength": 3000, 42 + "maxGraphemes": 300, 43 + "description": "The primary message content. May be an empty string, if there are embeds." 44 + }, 45 + "createdAt": { 46 + "type": "string", 47 + "format": "datetime", 48 + "description": "Client-declared timestamp when this message was originally created." 49 + }, 50 + "facets": { 51 + "type": "array", 52 + "description": "Annotations of text (mentions, URLs, etc)", 53 + "items": { 54 + "type": "ref", 55 + "ref": "place.stream.richtext.defs#facetView" 56 + } 57 + }, 58 + "streamer": { 59 + "type": "string", 60 + "format": "did", 61 + "description": "The DID of the streamer whose chat this is." 62 + }, 63 + "reply": { 64 + "type": "ref", 65 + "ref": "place.stream.chat.message#replyRef" 66 } 67 } 68 }
+56
lexicons/place/stream/richtext/defs.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.richtext.defs", 4 + "defs": { 5 + "facetView": { 6 + "type": "object", 7 + "description": "Annotation of a sub-string within rich text.", 8 + "required": ["index", "features"], 9 + "properties": { 10 + "index": { "type": "ref", "ref": "app.bsky.richtext.facet#byteSlice" }, 11 + "features": { 12 + "type": "array", 13 + "items": { 14 + "type": "union", 15 + "refs": [ 16 + "app.bsky.richtext.facet#mention", 17 + "app.bsky.richtext.facet#link", 18 + "#censor" 19 + ] 20 + } 21 + } 22 + } 23 + }, 24 + "censor": { 25 + "type": "object", 26 + "description": "Indicates that the text in the given index has been censored.", 27 + "properties": { 28 + "reason": { "type": "string" }, 29 + "categories": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "knownValues": [ 34 + "place.stream.richtext.defs#discriminatory", 35 + "place.stream.richtext.defs#sexually_explicit", 36 + "place.stream.richtext.defs#profanity" 37 + ] 38 + }, 39 + "description": "Categories of censored content" 40 + } 41 + } 42 + }, 43 + "discriminatory": { 44 + "type": "token", 45 + "description": "Indicates that the text has been censored due to discriminatory content." 46 + }, 47 + "sexually_explicit": { 48 + "type": "token", 49 + "description": "Indicates that the text has been censored due to sexually explicit content." 50 + }, 51 + "profanity": { 52 + "type": "token", 53 + "description": "Indicates that the text has been censored due to profanity." 54 + } 55 + } 56 + }
+6 -58
pkg/api/api.go
··· 35 "stream.place/streamplace/pkg/director" 36 apierrors "stream.place/streamplace/pkg/errors" 37 "stream.place/streamplace/pkg/linking" 38 "stream.place/streamplace/pkg/log" 39 "stream.place/streamplace/pkg/media" 40 "stream.place/streamplace/pkg/mist/mistconfig" ··· 56 CLI *config.CLI 57 Model model.Model 58 StatefulDB *statedb.StatefulDB 59 Updater *Updater 60 Signer *eip712.EIP712Signer 61 Mimes map[string]string ··· 93 mu sync.RWMutex 94 } 95 96 - func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, statefulDB *statedb.StatefulDB, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director, op *oatproxy.OATProxy) (*StreamplaceAPI, error) { 97 updater, err := PrepareUpdater(cli) 98 if err != nil { 99 return nil, err ··· 117 sessionsLock: sync.RWMutex{}, 118 rtmpSessions: make(map[string]*media.RTMPSession), 119 rtmpSessionsLock: sync.Mutex{}, 120 } 121 a.Mimes, err = updater.GetMimes() 122 if err != nil { ··· 152 Recorder: metrics.NewRecorder(metrics.Config{}), 153 }) 154 var xrpc http.Handler 155 - xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus) 156 if err != nil { 157 return nil, err 158 } ··· 203 addHandle(apiRouter, "GET", "/api/chat/:repoDID", a.HandleChat(ctx)) 204 addHandle(apiRouter, "GET", "/api/websocket/:repoDID", a.HandleWebsocket(ctx)) 205 addHandle(apiRouter, "GET", "/api/livestream/:repoDID", a.HandleLivestream(ctx)) 206 - addHandle(apiRouter, "GET", "/api/segment/recent", a.HandleRecentSegments(ctx)) 207 - addHandle(apiRouter, "GET", "/api/segment/recent/:repoDID", a.HandleUserRecentSegments(ctx)) 208 addHandle(apiRouter, "GET", "/api/bluesky/resolve/:handle", a.HandleBlueskyResolve(ctx)) 209 addHandle(apiRouter, "GET", "/api/view-count/:user", a.HandleViewCount(ctx)) 210 addHandle(apiRouter, "GET", "/api/clip/:user/:file", a.HandleClip(ctx)) ··· 271 if err != nil { 272 return nil, err 273 } 274 - linker, err := linking.NewLinker(ctx, bs) 275 if err != nil { 276 return nil, err 277 } ··· 558 return 559 } 560 w.WriteHeader(201) 561 - } 562 - } 563 - 564 - func (a *StreamplaceAPI) HandleRecentSegments(ctx context.Context) httprouter.Handle { 565 - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 566 - segs, err := a.Model.MostRecentSegments() 567 - if err != nil { 568 - apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 569 - return 570 - } 571 - bs, err := json.Marshal(segs) 572 - if err != nil { 573 - apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 574 - return 575 - } 576 - w.Header().Add("Content-Type", "application/json") 577 - if _, err := w.Write(bs); err != nil { 578 - log.Error(ctx, "error writing response", "error", err) 579 - } 580 - } 581 - } 582 - 583 - func (a *StreamplaceAPI) HandleUserRecentSegments(ctx context.Context) httprouter.Handle { 584 - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 585 - user := params.ByName("repoDID") 586 - if user == "" { 587 - apierrors.WriteHTTPBadRequest(w, "user required", nil) 588 - return 589 - } 590 - user, err := a.NormalizeUser(ctx, user) 591 - if err != nil { 592 - apierrors.WriteHTTPNotFound(w, "user not found", err) 593 - return 594 - } 595 - seg, err := a.Model.LatestSegmentForUser(user) 596 - if err != nil { 597 - apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 598 - return 599 - } 600 - streamplaceSeg, err := seg.ToStreamplaceSegment() 601 - if err != nil { 602 - apierrors.WriteHTTPInternalServerError(w, "could not convert segment to streamplace segment", err) 603 - return 604 - } 605 - bs, err := json.Marshal(streamplaceSeg) 606 - if err != nil { 607 - apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 608 - return 609 - } 610 - w.Header().Add("Content-Type", "application/json") 611 - if _, err := w.Write(bs); err != nil { 612 - log.Error(ctx, "error writing response", "error", err) 613 - } 614 } 615 } 616
··· 35 "stream.place/streamplace/pkg/director" 36 apierrors "stream.place/streamplace/pkg/errors" 37 "stream.place/streamplace/pkg/linking" 38 + "stream.place/streamplace/pkg/localdb" 39 "stream.place/streamplace/pkg/log" 40 "stream.place/streamplace/pkg/media" 41 "stream.place/streamplace/pkg/mist/mistconfig" ··· 57 CLI *config.CLI 58 Model model.Model 59 StatefulDB *statedb.StatefulDB 60 + LocalDB localdb.LocalDB 61 Updater *Updater 62 Signer *eip712.EIP712Signer 63 Mimes map[string]string ··· 95 mu sync.RWMutex 96 } 97 98 + func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, statefulDB *statedb.StatefulDB, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director, op *oatproxy.OATProxy, ldb localdb.LocalDB) (*StreamplaceAPI, error) { 99 updater, err := PrepareUpdater(cli) 100 if err != nil { 101 return nil, err ··· 119 sessionsLock: sync.RWMutex{}, 120 rtmpSessions: make(map[string]*media.RTMPSession), 121 rtmpSessionsLock: sync.Mutex{}, 122 + LocalDB: ldb, 123 } 124 a.Mimes, err = updater.GetMimes() 125 if err != nil { ··· 155 Recorder: metrics.NewRecorder(metrics.Config{}), 156 }) 157 var xrpc http.Handler 158 + xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB) 159 if err != nil { 160 return nil, err 161 } ··· 206 addHandle(apiRouter, "GET", "/api/chat/:repoDID", a.HandleChat(ctx)) 207 addHandle(apiRouter, "GET", "/api/websocket/:repoDID", a.HandleWebsocket(ctx)) 208 addHandle(apiRouter, "GET", "/api/livestream/:repoDID", a.HandleLivestream(ctx)) 209 addHandle(apiRouter, "GET", "/api/bluesky/resolve/:handle", a.HandleBlueskyResolve(ctx)) 210 addHandle(apiRouter, "GET", "/api/view-count/:user", a.HandleViewCount(ctx)) 211 addHandle(apiRouter, "GET", "/api/clip/:user/:file", a.HandleClip(ctx)) ··· 272 if err != nil { 273 return nil, err 274 } 275 + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) 276 if err != nil { 277 return nil, err 278 } ··· 559 return 560 } 561 w.WriteHeader(201) 562 } 563 } 564
+3 -3
pkg/api/api_internal.go
··· 298 errors.WriteHTTPBadRequest(w, "id required", nil) 299 return 300 } 301 - segment, err := a.Model.GetSegment(id) 302 if err != nil { 303 errors.WriteHTTPBadRequest(w, err.Error(), err) 304 return ··· 418 errors.WriteHTTPInternalServerError(w, "unable to get chat posts", err) 419 return 420 } 421 - spmsg, err := msg.ToStreamplaceMessageView() 422 if err != nil { 423 errors.WriteHTTPInternalServerError(w, "unable to convert chat message to streamplace message view", err) 424 return ··· 553 } 554 after := time.Now().Add(-time.Duration(secs) * time.Second) 555 w.Header().Set("Content-Type", "video/mp4") 556 - err = media.ClipUser(ctx, a.Model, a.CLI, user, w, nil, &after) 557 if err != nil { 558 errors.WriteHTTPInternalServerError(w, "unable to clip user", err) 559 return
··· 298 errors.WriteHTTPBadRequest(w, "id required", nil) 299 return 300 } 301 + segment, err := a.LocalDB.GetSegment(id) 302 if err != nil { 303 errors.WriteHTTPBadRequest(w, err.Error(), err) 304 return ··· 418 errors.WriteHTTPInternalServerError(w, "unable to get chat posts", err) 419 return 420 } 421 + spmsg, err := msg.ToStreamplaceMessageView(nil) 422 if err != nil { 423 errors.WriteHTTPInternalServerError(w, "unable to convert chat message to streamplace message view", err) 424 return ··· 553 } 554 after := time.Now().Add(-time.Duration(secs) * time.Second) 555 w.Header().Set("Content-Type", "video/mp4") 556 + err = media.ClipUser(ctx, a.LocalDB, a.CLI, user, w, nil, &after) 557 if err != nil { 558 errors.WriteHTTPInternalServerError(w, "unable to clip user", err) 559 return
+1 -1
pkg/api/playback.go
··· 272 errors.WriteHTTPNotFound(w, "user not found", err) 273 return 274 } 275 - thumb, err := a.Model.LatestThumbnailForUser(user) 276 if err != nil { 277 errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) 278 return
··· 272 errors.WriteHTTPNotFound(w, "user not found", err) 273 return 274 } 275 + thumb, err := a.LocalDB.LatestThumbnailForUser(user) 276 if err != nil { 277 errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) 278 return
+1 -1
pkg/api/websocket.go
··· 181 }() 182 183 go func() { 184 - seg, err := a.Model.LatestSegmentForUser(repoDID) 185 if err != nil { 186 log.Error(ctx, "could not get replies", "error", err) 187 return
··· 181 }() 182 183 go func() { 184 + seg, err := a.LocalDB.LatestSegmentForUser(repoDID) 185 if err != nil { 186 log.Error(ctx, "could not get replies", "error", err) 187 return
+30 -7
pkg/aqtime/aqtime.go
··· 7 "time" 8 ) 9 10 var RE *regexp.Regexp 11 - var Pattern string = `^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z$` 12 - 13 - type AQTime string 14 15 func init() { 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 } 18 19 var fstr = "2006-01-02T15:04:05.000Z" 20 21 // return a consistently formatted timestamp 22 func FromMillis(ms int64) AQTime { ··· 29 } 30 31 func FromString(str string) (AQTime, error) { 32 - bits := RE.FindStringSubmatch(str) 33 - if bits == nil { 34 - return "", fmt.Errorf("bad time format, expected=%s got=%s", fstr, str) 35 } 36 - return AQTime(str), nil 37 } 38 39 func FromTime(t time.Time) AQTime {
··· 7 "time" 8 ) 9 10 + // RE matches the canonical internal format: 2006-01-02T15:04:05.000Z 11 + // It also accepts the file-safe variant with dashes/dots swapped, for backward compat. 12 var RE *regexp.Regexp 13 + var Pattern string = `(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z` 14 15 func init() { 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 } 18 19 var fstr = "2006-01-02T15:04:05.000Z" 20 + 21 + type AQTime string 22 23 // return a consistently formatted timestamp 24 func FromMillis(ms int64) AQTime { ··· 31 } 32 33 func FromString(str string) (AQTime, error) { 34 + // Reject -00:00 (valid RFC 3339 but disallowed by ATProto) 35 + if strings.HasSuffix(str, "-00:00") { 36 + return "", fmt.Errorf("bad time format, -00:00 timezone offset is not allowed, got=%s", str) 37 + } 38 + 39 + t, err := time.Parse(time.RFC3339Nano, str) 40 + if err != nil { 41 + // Fall back to file-safe variant (e.g. 2024-09-13T18-10-17-090Z) 42 + if bits := RE.FindStringSubmatch(str); bits != nil { 43 + if bits[2] < "01" || bits[2] > "12" || bits[3] < "01" || bits[3] > "31" || 44 + bits[4] > "23" || bits[5] > "59" || bits[6] > "60" { 45 + return "", fmt.Errorf("bad time format, invalid date/time values in %s", str) 46 + } 47 + return AQTime(str), nil 48 + } 49 + return "", fmt.Errorf("bad time format: %w", err) 50 } 51 + 52 + // Reject if UTC normalization results in a negative year 53 + utc := t.UTC() 54 + if utc.Year() < 0 { 55 + return "", fmt.Errorf("bad time format, datetime normalizes to negative year: %s", str) 56 + } 57 + 58 + // Normalize to canonical UTC millisecond format 59 + return AQTime(utc.Format(fstr)), nil 60 } 61 62 func FromTime(t time.Time) AQTime {
+63 -2
pkg/aqtime/aqtime_test.go
··· 35 } 36 } 37 38 func TestBadCases(t *testing.T) { 39 for _, str := range []string{ 40 "prefix2024-09-13T18:10:17.090Z", 41 "2024-09-13T18-10-17-090Zsuffix", 42 "2024-09-13T18-10-17-090ZZZZ", 43 "2024-09-13T18-10-17*090ZZZZ", 44 } { 45 - _, err := FromString(str) 46 - require.Error(t, err) 47 } 48 }
··· 35 } 36 } 37 38 + // Valid ATProto datetime examples from the spec 39 + // https://atproto.com/specs/lexicon#datetime 40 + func TestATProtoValidCases(t *testing.T) { 41 + tests := []struct { 42 + input string 43 + wantMs string // expected millisecond portion after normalization 44 + wantHr string // expected hour after UTC normalization 45 + wantMin string 46 + }{ 47 + {"1985-04-12T23:20:50.123Z", "123", "23", "20"}, 48 + {"1985-04-12T23:20:50.123456Z", "123", "23", "20"}, 49 + {"1985-04-12T23:20:50.120Z", "120", "23", "20"}, 50 + {"1985-04-12T23:20:50.120000Z", "120", "23", "20"}, 51 + {"0001-01-01T00:00:00.000Z", "000", "00", "00"}, 52 + {"0000-01-01T00:00:00.000Z", "000", "00", "00"}, 53 + {"1985-04-12T23:20:50.12345678912345Z", "123", "23", "20"}, 54 + {"1985-04-12T23:20:50Z", "000", "23", "20"}, 55 + {"1985-04-12T23:20:50.0Z", "000", "23", "20"}, 56 + {"1985-04-12T23:20:50.123+00:00", "123", "23", "20"}, 57 + {"1985-04-12T23:20:50.123-07:00", "123", "06", "20"}, // 23+7=30 -> next day 06:20 58 + } 59 + for _, tt := range tests { 60 + t.Run(tt.input, func(t *testing.T) { 61 + aqt, err := FromString(tt.input) 62 + require.NoError(t, err, "input: %s", tt.input) 63 + _, _, _, hr, min, _, ms := aqt.Parts() 64 + require.Equal(t, tt.wantMs, ms, "millis mismatch for %s", tt.input) 65 + require.Equal(t, tt.wantHr, hr, "hour mismatch for %s", tt.input) 66 + require.Equal(t, tt.wantMin, min, "minute mismatch for %s", tt.input) 67 + }) 68 + } 69 + } 70 + 71 func TestBadCases(t *testing.T) { 72 for _, str := range []string{ 73 + // existing cases 74 "prefix2024-09-13T18:10:17.090Z", 75 "2024-09-13T18-10-17-090Zsuffix", 76 "2024-09-13T18-10-17-090ZZZZ", 77 "2024-09-13T18-10-17*090ZZZZ", 78 + // ATProto spec invalid examples 79 + "1985-04-12", 80 + "1985-04-12T23:20Z", 81 + "1985-04-12T23:20:5Z", 82 + "1985-04-12T23:20:50.123", 83 + "+001985-04-12T23:20:50.123Z", 84 + "23:20:50.123Z", 85 + "-1985-04-12T23:20:50.123Z", 86 + "1985-4-12T23:20:50.123Z", 87 + "01985-04-12T23:20:50.123Z", 88 + "1985-04-12T23:20:50.123+00", 89 + "1985-04-12T23:20:50.123+0000", 90 + // ISO-8601 strict capitalization 91 + "1985-04-12t23:20:50.123Z", 92 + "1985-04-12T23:20:50.123z", 93 + // RFC-3339, but not ISO-8601 94 + "1985-04-12T23:20:50.123-00:00", 95 + "1985-04-12 23:20:50.123Z", 96 + // timezone is required 97 + "1985-04-12T23:20:50.123", 98 + // syntax looks ok, but datetime is not valid 99 + "1985-04-12T23:99:50.123Z", 100 + "1985-00-12T23:20:50.123Z", 101 + // ISO-8601, but normalizes to a negative time 102 + "0000-01-01T00:00:00+01:00", 103 } { 104 + t.Run(str, func(t *testing.T) { 105 + _, err := FromString(str) 106 + require.Error(t, err, "expected error for: %s", str) 107 + }) 108 } 109 }
+2 -4
pkg/atproto/atproto.go
··· 231 "https://w3id.org/security/multikey/v1", 232 "https://w3id.org/security/suites/secp256k1-2019/v1", 233 }, 234 - "id": fmt.Sprintf("did:web:%s", host), 235 - "alsoKnownAs": []string{ 236 - fmt.Sprintf("at://%s", host), 237 - }, 238 "service": []map[string]any{ 239 { 240 "id": "#bsky_fg",
··· 231 "https://w3id.org/security/multikey/v1", 232 "https://w3id.org/security/suites/secp256k1-2019/v1", 233 }, 234 + "id": fmt.Sprintf("did:web:%s", host), 235 + "alsoKnownAs": []string{}, 236 "service": []map[string]any{ 237 { 238 "id": "#bsky_fg",
+9 -9
pkg/atproto/chat_message_test.go
··· 112 }) 113 // Reverse the messages slice to match expected order (most recent first) 114 slices.SortFunc(messages, func(a, b *streamplace.ChatDefs_MessageView) int { 115 - aTime := a.Record.Val.(*streamplace.ChatMessage).CreatedAt 116 - bTime := b.Record.Val.(*streamplace.ChatMessage).CreatedAt 117 if aTime < bTime { 118 return -1 119 } else if aTime > bTime { ··· 122 return 0 123 }) 124 slices.SortFunc(busMessages, func(a, b bus.Message) int { 125 - aTime := a.(*streamplace.ChatDefs_MessageView).Record.Val.(*streamplace.ChatMessage).CreatedAt 126 - bTime := b.(*streamplace.ChatDefs_MessageView).Record.Val.(*streamplace.ChatMessage).CreatedAt 127 if aTime < bTime { 128 return -1 129 } else if aTime > bTime { ··· 131 } 132 return 0 133 }) 134 - require.Equal(t, msg.Text, messages[0].Record.Val.(*streamplace.ChatMessage).Text) 135 - require.Equal(t, msg2.Text, messages[1].Record.Val.(*streamplace.ChatMessage).Text) 136 busMessage1 := busMessages[0].(*streamplace.ChatDefs_MessageView) 137 busMessage2 := busMessages[1].(*streamplace.ChatDefs_MessageView) 138 - require.Equal(t, msg.Text, busMessage1.Record.Val.(*streamplace.ChatMessage).Text) 139 - require.Equal(t, msg2.Text, busMessage2.Record.Val.(*streamplace.ChatMessage).Text) 140 141 rkey := strings.TrimPrefix(rec1.Uri, fmt.Sprintf("at://%s/place.stream.chat.message/", user.DID)) 142 ··· 162 return nil 163 }) 164 require.NoError(t, err) 165 - require.Equal(t, msg2.Text, messages[0].Record.Val.(*streamplace.ChatMessage).Text) 166 busMessage3 := busMessages[2].(*streamplace.ChatDefs_MessageView) 167 require.Equal(t, true, *busMessage3.Deleted) 168
··· 112 }) 113 // Reverse the messages slice to match expected order (most recent first) 114 slices.SortFunc(messages, func(a, b *streamplace.ChatDefs_MessageView) int { 115 + aTime := a.Record.ChatDefs_MessageRecordView.CreatedAt 116 + bTime := b.Record.ChatDefs_MessageRecordView.CreatedAt 117 if aTime < bTime { 118 return -1 119 } else if aTime > bTime { ··· 122 return 0 123 }) 124 slices.SortFunc(busMessages, func(a, b bus.Message) int { 125 + aTime := a.(*streamplace.ChatDefs_MessageView).Record.ChatDefs_MessageRecordView.CreatedAt 126 + bTime := b.(*streamplace.ChatDefs_MessageView).Record.ChatDefs_MessageRecordView.CreatedAt 127 if aTime < bTime { 128 return -1 129 } else if aTime > bTime { ··· 131 } 132 return 0 133 }) 134 + require.Equal(t, msg.Text, messages[0].Record.ChatDefs_MessageRecordView.Text) 135 + require.Equal(t, msg2.Text, messages[1].Record.ChatDefs_MessageRecordView.Text) 136 busMessage1 := busMessages[0].(*streamplace.ChatDefs_MessageView) 137 busMessage2 := busMessages[1].(*streamplace.ChatDefs_MessageView) 138 + require.Equal(t, msg.Text, busMessage1.Record.ChatDefs_MessageRecordView.Text) 139 + require.Equal(t, msg2.Text, busMessage2.Record.ChatDefs_MessageRecordView.Text) 140 141 rkey := strings.TrimPrefix(rec1.Uri, fmt.Sprintf("at://%s/place.stream.chat.message/", user.DID)) 142 ··· 162 return nil 163 }) 164 require.NoError(t, err) 165 + require.Equal(t, msg2.Text, messages[0].Record.ChatDefs_MessageRecordView.Text) 166 busMessage3 := busMessages[2].(*streamplace.ChatDefs_MessageView) 167 require.Equal(t, true, *busMessage3.Deleted) 168
+1 -1
pkg/atproto/firehose.go
··· 295 log.Error(ctx, "failed to delete chat message", "err", err) 296 continue 297 } 298 - mv, err := msg.ToStreamplaceMessageView() 299 if err != nil { 300 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 301 continue
··· 295 log.Error(ctx, "failed to delete chat message", "err", err) 296 continue 297 } 298 + mv, err := msg.ToStreamplaceMessageView(nil) 299 if err != nil { 300 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 301 continue
+1 -1
pkg/atproto/labeler_firehose.go
··· 182 log.Error(ctx, "failed to get chat message for label", "err", err) 183 continue 184 } 185 - chatView, err := msg.ToStreamplaceMessageView() 186 if err != nil { 187 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 188 continue
··· 182 log.Error(ctx, "failed to get chat message for label", "err", err) 183 continue 184 } 185 + chatView, err := msg.ToStreamplaceMessageView(nil) 186 if err != nil { 187 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 188 continue
+12
pkg/atproto/lexicon_repo_queries.go
··· 126 Value: &lexutil.LexiconTypeDecoder{Val: rec}, 127 }, nil 128 }
··· 126 Value: &lexutil.LexiconTypeDecoder{Val: rec}, 127 }, nil 128 } 129 + 130 + func LexiconRepoGetRepo(ctx context.Context, since string) ([]byte, error) { 131 + buf := bytes.Buffer{} 132 + 133 + repoLock.Lock() 134 + defer repoLock.Unlock() 135 + err := CarStore.ReadUserCar(ctx, RepoUser, since, true, &buf) 136 + if err != nil { 137 + return nil, fmt.Errorf("LexiconRepoGetRepo: failed to read user car: %w", err) 138 + } 139 + return buf.Bytes(), nil 140 + }
+2 -2
pkg/atproto/sync.go
··· 74 return fmt.Errorf("failed to create block: %w", err) 75 } 76 block, err = atsync.Model.GetBlock(ctx, rkey.String()) 77 - if err != nil { 78 return fmt.Errorf("failed to get block after we just saved it?!: %w", err) 79 } 80 streamplaceBlock, err := block.ToStreamplaceBlock() ··· 145 log.Error(ctx, "failed to retrieve just-saved chat message", "err", err) 146 return nil 147 } 148 - scm, err := mcm.ToStreamplaceMessageView() 149 if err != nil { 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 return nil
··· 74 return fmt.Errorf("failed to create block: %w", err) 75 } 76 block, err = atsync.Model.GetBlock(ctx, rkey.String()) 77 + if err != nil || block == nil { 78 return fmt.Errorf("failed to get block after we just saved it?!: %w", err) 79 } 80 streamplaceBlock, err := block.ToStreamplaceBlock() ··· 145 log.Error(ctx, "failed to retrieve just-saved chat message", "err", err) 146 return nil 147 } 148 + scm, err := mcm.ToStreamplaceMessageView(nil) 149 if err != nil { 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 return nil
+12 -5
pkg/cmd/streamplace.go
··· 29 "stream.place/streamplace/pkg/director" 30 "stream.place/streamplace/pkg/gstinit" 31 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 32 "stream.place/streamplace/pkg/log" 33 "stream.place/streamplace/pkg/media" 34 "stream.place/streamplace/pkg/notifications" ··· 237 return fmt.Errorf("error creating streamplace dir at %s:%w", cli.DataDir, err) 238 } 239 240 mod, err := model.MakeDB(cli.DataFilePath([]string{"index"})) 241 if err != nil { 242 return err ··· 291 return fmt.Errorf("failed to migrate: %w", err) 292 } 293 294 - mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync) 295 if err != nil { 296 return err 297 } ··· 379 DownstreamJWK: cli.AccessJWK, 380 ClientMetadata: clientMetadata, 381 Public: cli.PublicOAuth, 382 }) 383 - d := director.NewDirector(mm, mod, &cli, b, op, state, replicator) 384 - a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op) 385 if err != nil { 386 return err 387 } ··· 446 }) 447 448 group.Go(func() error { 449 - return storage.StartSegmentCleaner(ctx, mod, &cli) 450 }) 451 452 group.Go(func() error { 453 - return mod.StartSegmentCleaner(ctx) 454 }) 455 456 group.Go(func() error {
··· 29 "stream.place/streamplace/pkg/director" 30 "stream.place/streamplace/pkg/gstinit" 31 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 32 + "stream.place/streamplace/pkg/localdb" 33 "stream.place/streamplace/pkg/log" 34 "stream.place/streamplace/pkg/media" 35 "stream.place/streamplace/pkg/notifications" ··· 238 return fmt.Errorf("error creating streamplace dir at %s:%w", cli.DataDir, err) 239 } 240 241 + ldb, err := localdb.MakeDB(cli.LocalDBURL) 242 + if err != nil { 243 + return err 244 + } 245 + 246 mod, err := model.MakeDB(cli.DataFilePath([]string{"index"})) 247 if err != nil { 248 return err ··· 297 return fmt.Errorf("failed to migrate: %w", err) 298 } 299 300 + mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync, ldb) 301 if err != nil { 302 return err 303 } ··· 385 DownstreamJWK: cli.AccessJWK, 386 ClientMetadata: clientMetadata, 387 Public: cli.PublicOAuth, 388 + HTTPClient: &aqhttp.Client, 389 }) 390 + d := director.NewDirector(mm, mod, &cli, b, op, state, replicator, ldb) 391 + a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) 392 if err != nil { 393 return err 394 } ··· 453 }) 454 455 group.Go(func() error { 456 + return storage.StartSegmentCleaner(ctx, ldb, &cli) 457 }) 458 459 group.Go(func() error { 460 + return ldb.StartSegmentCleaner(ctx) 461 }) 462 463 group.Go(func() error {
+3
pkg/config/config.go
··· 56 Build *BuildFlags 57 DataDir string 58 DBURL string 59 EthAccountAddr string 60 EthKeystorePath string 61 EthPassword string ··· 242 cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations") 243 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") 244 fs.BoolVar(&cli.PlayerTelemetry, "player-telemetry", true, "enable player telemetry") 245 246 fs.Bool("external-signing", true, "DEPRECATED, does nothing.") 247 fs.Bool("insecure", false, "DEPRECATED, does nothing.")
··· 56 Build *BuildFlags 57 DataDir string 58 DBURL string 59 + LocalDBURL string 60 EthAccountAddr string 61 EthKeystorePath string 62 EthPassword string ··· 243 cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations") 244 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") 245 fs.BoolVar(&cli.PlayerTelemetry, "player-telemetry", true, "enable player telemetry") 246 + fs.StringVar(&cli.LocalDBURL, "local-db-url", "sqlite://$SP_DATA_DIR/localdb.sqlite", "URL of the local database to use for storing local data") 247 + cli.dataDirFlags = append(cli.dataDirFlags, &cli.LocalDBURL) 248 249 fs.Bool("external-signing", true, "DEPRECATED, does nothing.") 250 fs.Bool("insecure", false, "DEPRECATED, does nothing.")
+13
pkg/config/git/git.go
··· 82 homebrew := flag.Bool("homebrew", false, "print homebrew formula") 83 84 flag.Parse() 85 r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) 86 if err != nil { 87 return err
··· 82 homebrew := flag.Bool("homebrew", false, "print homebrew formula") 83 84 flag.Parse() 85 + 86 + // handle CF_PAGES environment fallback 87 + if os.Getenv("CF_PAGES") != "" && *javascript { 88 + out := `export const version = "unknown"; export const buildTime = 0; export const uuid = "00000000-0000-0000-0000-000000000000";` 89 + if *output != "" { 90 + if err := os.WriteFile(*output, []byte(out), 0644); err != nil { 91 + return err 92 + } 93 + } else { 94 + fmt.Print(out) 95 + } 96 + return nil 97 + } 98 r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) 99 if err != nil { 100 return err
+8 -1
pkg/director/director.go
··· 9 "golang.org/x/sync/errgroup" 10 "stream.place/streamplace/pkg/bus" 11 "stream.place/streamplace/pkg/config" 12 "stream.place/streamplace/pkg/log" 13 "stream.place/streamplace/pkg/media" 14 "stream.place/streamplace/pkg/model" ··· 32 op *oatproxy.OATProxy 33 statefulDB *statedb.StatefulDB 34 replicator replication.Replicator 35 } 36 37 - func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator) *Director { 38 return &Director{ 39 mm: mm, 40 mod: mod, ··· 45 op: op, 46 statefulDB: statefulDB, 47 replicator: replicator, 48 } 49 } 50 ··· 76 started: make(chan struct{}), 77 statefulDB: d.statefulDB, 78 replicator: d.replicator, 79 } 80 d.streamSessions[not.Segment.RepoDID] = ss 81 g.Go(func() error {
··· 9 "golang.org/x/sync/errgroup" 10 "stream.place/streamplace/pkg/bus" 11 "stream.place/streamplace/pkg/config" 12 + "stream.place/streamplace/pkg/localdb" 13 "stream.place/streamplace/pkg/log" 14 "stream.place/streamplace/pkg/media" 15 "stream.place/streamplace/pkg/model" ··· 33 op *oatproxy.OATProxy 34 statefulDB *statedb.StatefulDB 35 replicator replication.Replicator 36 + localDB localdb.LocalDB 37 } 38 39 + 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 { 40 return &Director{ 41 mm: mm, 42 mod: mod, ··· 47 op: op, 48 statefulDB: statefulDB, 49 replicator: replicator, 50 + localDB: ldb, 51 } 52 } 53 ··· 79 started: make(chan struct{}), 80 statefulDB: d.statefulDB, 81 replicator: d.replicator, 82 + // Initialize notification channels (buffered size 1 for coalescing) 83 + statusUpdateChan: make(chan struct{}, 1), 84 + originUpdateChan: make(chan struct{}, 1), 85 + localDB: d.localDB, 86 } 87 d.streamSessions[not.Segment.RepoDID] = ss 88 g.Go(func() error {
+97 -39
pkg/director/stream_session.go
··· 5 "context" 6 "fmt" 7 "net/url" 8 - "sync" 9 "time" 10 11 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 21 "stream.place/streamplace/pkg/bus" 22 "stream.place/streamplace/pkg/config" 23 "stream.place/streamplace/pkg/livepeer" 24 "stream.place/streamplace/pkg/log" 25 "stream.place/streamplace/pkg/media" 26 "stream.place/streamplace/pkg/model" ··· 44 segmentChan chan struct{} 45 lastStatus time.Time 46 lastStatusCID *string 47 - lastStatusLock sync.Mutex 48 lastOriginTime time.Time 49 - lastOriginLock sync.Mutex 50 - g *errgroup.Group 51 - started chan struct{} 52 - ctx context.Context 53 - packets []bus.PacketizedSegment 54 - statefulDB *statedb.StatefulDB 55 - replicator replication.Replicator 56 } 57 58 func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotification) error { ··· 111 112 close(ss.started) 113 114 - ss.Go(ctx, func() error { 115 - return ss.HandleMultistreamTargets(ctx) 116 }) 117 118 for { 119 select { 120 case <-ss.segmentChan: 121 // reset timer 122 case <-ctx.Done(): 123 return ss.g.Wait() 124 // case <-time.After(time.Minute * 1): 125 case <-time.After(ss.cli.StreamSessionTimeout): ··· 128 for _, r := range allRenditions { 129 ss.bus.EndSession(ctx, spseg.Creator, r.Name) 130 } 131 if notif.Local { 132 ss.Go(ctx, func() error { 133 return ss.DeleteStatus(spseg.Creator) ··· 164 aqt := aqtime.FromTime(notif.Segment.StartTime) 165 ctx = log.WithLogValues(ctx, "segID", notif.Segment.ID, "repoDID", notif.Segment.RepoDID, "timestamp", aqt.FileSafeString()) 166 notif.Segment.MediaData.Size = len(notif.Data) 167 - err := ss.mod.CreateSegment(notif.Segment) 168 if err != nil { 169 return fmt.Errorf("could not add segment to database: %w", err) 170 } ··· 188 } 189 190 if notif.Local { 191 - ss.Go(ctx, func() error { 192 - return ss.UpdateStatus(ctx, spseg.Creator) 193 - }) 194 - 195 - ss.Go(ctx, func() error { 196 - return ss.UpdateBroadcastOrigin(ctx) 197 - }) 198 } 199 200 if ss.cli.LivepeerGatewayURL != "" { ··· 250 if err != nil { 251 log.Error(ctx, "failed to enqueue notification task", "err", err) 252 } 253 - return ss.UpdateStatus(ctx, spseg.Creator) 254 }) 255 } else { 256 log.Warn(ctx, "no livestream detected in stream, skipping notification blast", "repoDID", spseg.Creator) ··· 282 return nil 283 } 284 defer lock.Unlock() 285 - oldThumb, err := ss.mod.LatestThumbnailForUser(not.Segment.RepoDID) 286 if err != nil { 287 return err 288 } ··· 301 if err != nil { 302 return err 303 } 304 - thumb := &model.Thumbnail{ 305 Format: "jpeg", 306 SegmentID: not.Segment.ID, 307 } 308 - err = ss.mod.CreateThumbnail(thumb) 309 if err != nil { 310 return err 311 } 312 return nil 313 } 314 315 - func (ss *StreamSession) UpdateStatus(ctx context.Context, repoDID string) error { 316 - ctx = log.WithLogValues(ctx, "func", "UpdateStatus") 317 - ss.lastStatusLock.Lock() 318 - defer ss.lastStatusLock.Unlock() 319 - if time.Since(ss.lastStatus) < time.Minute { 320 - log.Debug(ctx, "not updating status, last status was less than 1 minute ago") 321 - return nil 322 } 323 324 client, err := ss.GetClientByDID(repoDID) 325 if err != nil { ··· 421 422 func (ss *StreamSession) DeleteStatus(repoDID string) error { 423 // need a special extra context because the stream session context is already cancelled 424 ctx := log.WithLogValues(context.Background(), "func", "DeleteStatus", "repoDID", repoDID) 425 - ss.lastStatusLock.Lock() 426 - defer ss.lastStatusLock.Unlock() 427 if ss.lastStatusCID == nil { 428 log.Debug(ctx, "no status cid to delete") 429 return nil ··· 452 453 var originUpdateInterval = time.Second * 30 454 455 - func (ss *StreamSession) UpdateBroadcastOrigin(ctx context.Context) error { 456 - ctx = log.WithLogValues(ctx, "func", "UpdateStatus") 457 - ss.lastOriginLock.Lock() 458 - defer ss.lastOriginLock.Unlock() 459 - if time.Since(ss.lastOriginTime) < originUpdateInterval { 460 - log.Debug(ctx, "not updating origin, last origin was less than 30 seconds ago") 461 - return nil 462 } 463 broadcaster := fmt.Sprintf("did:web:%s", ss.cli.BroadcasterHost) 464 origin := streamplace.BroadcastOrigin{ 465 Streamer: ss.repoDID,
··· 5 "context" 6 "fmt" 7 "net/url" 8 "time" 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 20 "stream.place/streamplace/pkg/bus" 21 "stream.place/streamplace/pkg/config" 22 "stream.place/streamplace/pkg/livepeer" 23 + "stream.place/streamplace/pkg/localdb" 24 "stream.place/streamplace/pkg/log" 25 "stream.place/streamplace/pkg/media" 26 "stream.place/streamplace/pkg/model" ··· 44 segmentChan chan struct{} 45 lastStatus time.Time 46 lastStatusCID *string 47 lastOriginTime time.Time 48 + localDB localdb.LocalDB 49 + 50 + // Channels for background workers 51 + statusUpdateChan chan struct{} // Signal to update status 52 + originUpdateChan chan struct{} // Signal to update broadcast origin 53 + 54 + g *errgroup.Group 55 + started chan struct{} 56 + ctx context.Context 57 + packets []bus.PacketizedSegment 58 + statefulDB *statedb.StatefulDB 59 + replicator replication.Replicator 60 } 61 62 func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotification) error { ··· 115 116 close(ss.started) 117 118 + // Start background workers for status and origin updates 119 + ss.g.Go(func() error { 120 + return ss.statusUpdateLoop(ctx, spseg.Creator) 121 + }) 122 + ss.g.Go(func() error { 123 + return ss.originUpdateLoop(ctx) 124 }) 125 126 + if notif.Local { 127 + ss.Go(ctx, func() error { 128 + return ss.HandleMultistreamTargets(ctx) 129 + }) 130 + } 131 + 132 for { 133 select { 134 case <-ss.segmentChan: 135 // reset timer 136 case <-ctx.Done(): 137 + // Signal all background workers to stop 138 return ss.g.Wait() 139 // case <-time.After(time.Minute * 1): 140 case <-time.After(ss.cli.StreamSessionTimeout): ··· 143 for _, r := range allRenditions { 144 ss.bus.EndSession(ctx, spseg.Creator, r.Name) 145 } 146 + // Signal background workers to stop 147 if notif.Local { 148 ss.Go(ctx, func() error { 149 return ss.DeleteStatus(spseg.Creator) ··· 180 aqt := aqtime.FromTime(notif.Segment.StartTime) 181 ctx = log.WithLogValues(ctx, "segID", notif.Segment.ID, "repoDID", notif.Segment.RepoDID, "timestamp", aqt.FileSafeString()) 182 notif.Segment.MediaData.Size = len(notif.Data) 183 + err := ss.localDB.CreateSegment(notif.Segment) 184 if err != nil { 185 return fmt.Errorf("could not add segment to database: %w", err) 186 } ··· 204 } 205 206 if notif.Local { 207 + ss.UpdateStatus(ctx, spseg.Creator) 208 + ss.UpdateBroadcastOrigin(ctx) 209 } 210 211 if ss.cli.LivepeerGatewayURL != "" { ··· 261 if err != nil { 262 log.Error(ctx, "failed to enqueue notification task", "err", err) 263 } 264 + ss.UpdateStatus(ctx, spseg.Creator) 265 + return nil 266 }) 267 } else { 268 log.Warn(ctx, "no livestream detected in stream, skipping notification blast", "repoDID", spseg.Creator) ··· 294 return nil 295 } 296 defer lock.Unlock() 297 + oldThumb, err := ss.localDB.LatestThumbnailForUser(not.Segment.RepoDID) 298 if err != nil { 299 return err 300 } ··· 313 if err != nil { 314 return err 315 } 316 + thumb := &localdb.Thumbnail{ 317 Format: "jpeg", 318 SegmentID: not.Segment.ID, 319 } 320 + err = ss.localDB.CreateThumbnail(thumb) 321 if err != nil { 322 return err 323 } 324 return nil 325 } 326 327 + // UpdateStatus signals the background worker to update status (non-blocking) 328 + func (ss *StreamSession) UpdateStatus(ctx context.Context, repoDID string) { 329 + select { 330 + case ss.statusUpdateChan <- struct{}{}: 331 + default: 332 + // Channel full, signal already pending 333 } 334 + } 335 + 336 + // statusUpdateLoop runs as a background goroutine for the session lifetime 337 + func (ss *StreamSession) statusUpdateLoop(ctx context.Context, repoDID string) error { 338 + ctx = log.WithLogValues(ctx, "func", "statusUpdateLoop") 339 + for { 340 + select { 341 + case <-ctx.Done(): 342 + return nil 343 + case <-ss.statusUpdateChan: 344 + if time.Since(ss.lastStatus) < time.Minute { 345 + log.Debug(ctx, "not updating status, last status was less than 1 minute ago") 346 + continue 347 + } 348 + if err := ss.doUpdateStatus(ctx, repoDID); err != nil { 349 + log.Error(ctx, "failed to update status", "error", err) 350 + } 351 + } 352 + } 353 + } 354 + 355 + // doUpdateStatus performs the actual status update work 356 + func (ss *StreamSession) doUpdateStatus(ctx context.Context, repoDID string) error { 357 + ctx = log.WithLogValues(ctx, "func", "doUpdateStatus") 358 359 client, err := ss.GetClientByDID(repoDID) 360 if err != nil { ··· 456 457 func (ss *StreamSession) DeleteStatus(repoDID string) error { 458 // need a special extra context because the stream session context is already cancelled 459 + // No lock needed - this runs during teardown after the background worker has exited 460 ctx := log.WithLogValues(context.Background(), "func", "DeleteStatus", "repoDID", repoDID) 461 if ss.lastStatusCID == nil { 462 log.Debug(ctx, "no status cid to delete") 463 return nil ··· 486 487 var originUpdateInterval = time.Second * 30 488 489 + // UpdateBroadcastOrigin signals the background worker to update origin (non-blocking) 490 + func (ss *StreamSession) UpdateBroadcastOrigin(ctx context.Context) { 491 + select { 492 + case ss.originUpdateChan <- struct{}{}: 493 + default: 494 + // Channel full, signal already pending 495 + } 496 + } 497 + 498 + // originUpdateLoop runs as a background goroutine for the session lifetime 499 + func (ss *StreamSession) originUpdateLoop(ctx context.Context) error { 500 + ctx = log.WithLogValues(ctx, "func", "originUpdateLoop") 501 + for { 502 + select { 503 + case <-ctx.Done(): 504 + return nil 505 + case <-ss.originUpdateChan: 506 + if time.Since(ss.lastOriginTime) < originUpdateInterval { 507 + log.Debug(ctx, "not updating origin, last origin was less than 30 seconds ago") 508 + continue 509 + } 510 + if err := ss.doUpdateBroadcastOrigin(ctx); err != nil { 511 + log.Error(ctx, "failed to update broadcast origin", "error", err) 512 + } 513 + } 514 } 515 + } 516 + 517 + // doUpdateBroadcastOrigin performs the actual broadcast origin update work 518 + func (ss *StreamSession) doUpdateBroadcastOrigin(ctx context.Context) error { 519 + ctx = log.WithLogValues(ctx, "func", "doUpdateBroadcastOrigin") 520 + 521 broadcaster := fmt.Sprintf("did:web:%s", ss.cli.BroadcasterHost) 522 origin := streamplace.BroadcastOrigin{ 523 Streamer: ss.repoDID,
+1 -4
pkg/integrations/discord/send-chat.go
··· 17 18 func SendChat(ctx context.Context, w *discordtypes.Webhook, did string, scm *streamplace.ChatDefs_MessageView) error { 19 20 - msg, ok := scm.Record.Val.(*streamplace.ChatMessage) 21 - if !ok { 22 - return fmt.Errorf("failed to cast chat message to streamplace chat message") 23 - } 24 25 avatarURL, err := GetAvatarURL(ctx, did) 26 if err != nil {
··· 17 18 func SendChat(ctx context.Context, w *discordtypes.Webhook, did string, scm *streamplace.ChatDefs_MessageView) error { 19 20 + msg := scm.Record.ChatDefs_MessageRecordView 21 22 avatarURL, err := GetAvatarURL(ctx, did) 23 if err != nil {
+1 -1
pkg/integrations/discord/send-livestream.go
··· 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 } else { 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 - payload.Embeds[0].URL = fmt.Sprintf("%s/%s", *ls.Url, lsv.Author.Handle) 71 } 72 } 73
··· 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 } else { 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 + payload.Embeds[0].URL = *ls.Url 71 } 72 } 73
+7 -8
pkg/integrations/webhook/manager.go
··· 14 // SendChatWebhook sends chat message to a specific webhook 15 func SendChatWebhook(ctx context.Context, webhook *streamplace.ServerDefs_Webhook, authorDID string, scm *streamplace.ChatDefs_MessageView) error { 16 // Check if message should be muted 17 - if msg, ok := scm.Record.Val.(*streamplace.ChatMessage); ok { 18 - if len(webhook.MuteWords) > 0 { 19 - messageText := strings.ToLower(msg.Text) 20 - for _, muteWord := range webhook.MuteWords { 21 - if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 - // Message contains a mute word, skip forwarding 23 - return nil 24 - } 25 } 26 } 27 }
··· 14 // SendChatWebhook sends chat message to a specific webhook 15 func SendChatWebhook(ctx context.Context, webhook *streamplace.ServerDefs_Webhook, authorDID string, scm *streamplace.ChatDefs_MessageView) error { 16 // Check if message should be muted 17 + msg := scm.Record.ChatDefs_MessageRecordView 18 + if len(webhook.MuteWords) > 0 { 19 + messageText := strings.ToLower(msg.Text) 20 + for _, muteWord := range webhook.MuteWords { 21 + if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 + // Message contains a mute word, skip forwarding 23 + return nil 24 } 25 } 26 }
+139 -11
pkg/linking/linking.go
··· 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "net/url" 9 10 "golang.org/x/net/html" 11 "stream.place/streamplace/pkg/streamplace" 12 ) 13 14 type Linker struct { 15 BaseHTML []byte 16 } 17 18 - func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { 19 _, err := html.Parse(bytes.NewReader(baseHTML)) 20 if err != nil { 21 return nil, err 22 } 23 24 - return &Linker{BaseHTML: baseHTML}, nil 25 } 26 27 type PageConfig struct { 28 Title string 29 Metas []MetaTag 30 SentryDSN string 31 } 32 33 // Define all meta tags in a structured way ··· 37 Content string 38 } 39 40 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 41 if u == nil { 42 return nil, errors.New("url is nil") ··· 49 return nil, errors.New("livestream view is not a livestream") 50 } 51 52 - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) 53 outURL := u.String() 54 - 55 - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) 56 57 thumbURL, _ := url.Parse(u.String()) 58 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 66 // Facebook Meta Tags 67 {Type: "property", Key: "og:url", Content: u.String()}, 68 {Type: "property", Key: "og:type", Content: "website"}, 69 - {Type: "property", Key: "og:title", Content: titleStr}, 70 {Type: "property", Key: "og:description", Content: ls.Title}, 71 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 72 ··· 74 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 75 {Type: "property", Key: "twitter:domain", Content: u.Host}, 76 {Type: "property", Key: "twitter:url", Content: outURL}, 77 - {Type: "name", Key: "twitter:title", Content: titleStr}, 78 {Type: "name", Key: "twitter:description", Content: ls.Title}, 79 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 80 } 81 82 return l.GenerateHTML(ctx, &PageConfig{ 83 - Title: pageTitle, 84 Metas: metaTags, 85 SentryDSN: sentryDSN, 86 }) ··· 103 {Type: "property", Key: "og:url", Content: u.String()}, 104 {Type: "property", Key: "og:type", Content: "website"}, 105 {Type: "property", Key: "og:title", Content: "Stream.place"}, 106 - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 107 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 108 109 // Twitter Meta Tags ··· 111 {Type: "property", Key: "twitter:domain", Content: u.Host}, 112 {Type: "property", Key: "twitter:url", Content: u.String()}, 113 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 114 - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 115 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 116 } 117 118 return l.GenerateHTML(ctx, &PageConfig{ 119 - Title: "Stream.place", 120 Metas: metaTags, 121 SentryDSN: sentryDSN, 122 })
··· 3 import ( 4 "bytes" 5 "context" 6 + "encoding/json" 7 "errors" 8 "fmt" 9 + "log" 10 "net/url" 11 12 "golang.org/x/net/html" 13 + "stream.place/streamplace/pkg/config" 14 + "stream.place/streamplace/pkg/statedb" 15 "stream.place/streamplace/pkg/streamplace" 16 ) 17 18 type Linker struct { 19 BaseHTML []byte 20 + sdb *statedb.StatefulDB 21 + cli *config.CLI 22 } 23 24 + func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { 25 _, err := html.Parse(bytes.NewReader(baseHTML)) 26 if err != nil { 27 return nil, err 28 } 29 30 + return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil 31 } 32 33 type PageConfig struct { 34 Title string 35 Metas []MetaTag 36 SentryDSN string 37 + Branding []string 38 } 39 40 // Define all meta tags in a structured way ··· 44 Content string 45 } 46 47 + var BrandingAssetList = [...]string{ 48 + "siteTitle", 49 + "siteDescription", 50 + "primaryColor", 51 + "accentColor", 52 + "defaultStreamer", 53 + "mainLogo", 54 + "favicon", 55 + "sidebarBg", 56 + "legalLinks", 57 + } 58 + 59 + // fetch branding assets for a given broadcaster DID 60 + func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { 61 + ret := make([]streamplace.BrandingGetBranding_BrandingAsset, 0) 62 + for _, asset := range BrandingAssetList { 63 + blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) 64 + if err != nil { 65 + // this can probably include a 'record not found' error, in which case we skip 66 + log.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) 67 + continue 68 + } 69 + asset := streamplace.BrandingGetBranding_BrandingAsset{ 70 + Key: blob.Key, 71 + MimeType: blob.MimeType, 72 + } 73 + 74 + if blob.Width != nil { 75 + w := int64(*blob.Width) 76 + asset.Width = &w 77 + } 78 + if blob.Height != nil { 79 + h := int64(*blob.Height) 80 + asset.Height = &h 81 + } 82 + 83 + // process based on mime type 84 + if blob.MimeType == "text/plain" { 85 + str := string(blob.Data) 86 + asset.Data = &str 87 + } else { 88 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) 89 + asset.Url = &url 90 + } 91 + ret = append(ret, asset) 92 + } 93 + 94 + return ret, nil 95 + } 96 + 97 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 98 if u == nil { 99 return nil, errors.New("url is nil") ··· 106 return nil, errors.New("livestream view is not a livestream") 107 } 108 109 + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) 110 outURL := u.String() 111 112 thumbURL, _ := url.Parse(u.String()) 113 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 121 // Facebook Meta Tags 122 {Type: "property", Key: "og:url", Content: u.String()}, 123 {Type: "property", Key: "og:type", Content: "website"}, 124 {Type: "property", Key: "og:description", Content: ls.Title}, 125 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 126 ··· 128 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 129 {Type: "property", Key: "twitter:domain", Content: u.Host}, 130 {Type: "property", Key: "twitter:url", Content: outURL}, 131 {Type: "name", Key: "twitter:description", Content: ls.Title}, 132 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 133 } 134 + brandingTitle := "streamplace node" 135 + if l.sdb != nil && l.cli != nil { 136 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 137 + if err == nil { 138 + for i := range branding { 139 + val := branding[i] 140 + if val.Key == "siteTitle" && val.Data != nil { 141 + brandingTitle = *val.Data 142 + } 143 + marshalledJson, err := json.Marshal(val) 144 + if err != nil { 145 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 146 + continue 147 + } 148 + metaTags = append(metaTags, MetaTag{ 149 + Type: "name", 150 + Key: "internal-brand:" + val.Key, 151 + Content: string(marshalledJson), 152 + }) 153 + } 154 + } else { 155 + // log but we should not block rendering 156 + fmt.Printf("error fetching branding assets: %v\n", err) 157 + } 158 + } 159 + 160 + // do twitter/og title after 161 + metaTags = append(metaTags, MetaTag{ 162 + Type: "property", 163 + Key: "og:title", 164 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 165 + }) 166 + metaTags = append(metaTags, MetaTag{ 167 + Type: "name", 168 + Key: "twitter:title", 169 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 170 + }) 171 172 return l.GenerateHTML(ctx, &PageConfig{ 173 + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), 174 Metas: metaTags, 175 SentryDSN: sentryDSN, 176 }) ··· 193 {Type: "property", Key: "og:url", Content: u.String()}, 194 {Type: "property", Key: "og:type", Content: "website"}, 195 {Type: "property", Key: "og:title", Content: "Stream.place"}, 196 + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, 197 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 198 199 // Twitter Meta Tags ··· 201 {Type: "property", Key: "twitter:domain", Content: u.Host}, 202 {Type: "property", Key: "twitter:url", Content: u.String()}, 203 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 204 + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, 205 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 206 } 207 208 + brandingTitle := "streamplace node" 209 + if l.sdb != nil && l.cli != nil { 210 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 211 + if err == nil { 212 + for i := range branding { 213 + val := branding[i] 214 + if val.Key == "siteTitle" && val.Data != nil { 215 + brandingTitle = *val.Data 216 + } 217 + marshalledJson, err := json.Marshal(val) 218 + if err != nil { 219 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 220 + continue 221 + } 222 + metaTags = append(metaTags, MetaTag{ 223 + Type: "name", 224 + Key: "internal-brand:" + val.Key, 225 + Content: string(marshalledJson), 226 + }) 227 + } 228 + } else { 229 + // log but we should not block rendering 230 + fmt.Printf("error fetching branding assets: %v\n", err) 231 + } 232 + } 233 + 234 + // do twitter/og title after 235 + metaTags = append(metaTags, MetaTag{ 236 + Type: "property", 237 + Key: "og:title", 238 + Content: brandingTitle, 239 + }) 240 + metaTags = append(metaTags, MetaTag{ 241 + Type: "name", 242 + Key: "twitter:title", 243 + Content: brandingTitle, 244 + }) 245 + 246 return l.GenerateHTML(ctx, &PageConfig{ 247 + Title: brandingTitle, 248 Metas: metaTags, 249 SentryDSN: sentryDSN, 250 })
+2 -2
pkg/linking/linking_test.go
··· 29 30 func TestNewLinker(t *testing.T) { 31 index := IndexHTML(t) 32 - linker, err := NewLinker(context.Background(), index) 33 require.NoError(t, err) 34 require.NotNil(t, linker) 35 } 36 37 func TestGenerateLinkCard(t *testing.T) { 38 index := IndexHTML(t) 39 - linker, err := NewLinker(context.Background(), index) 40 require.NoError(t, err) 41 require.NotNil(t, linker) 42
··· 29 30 func TestNewLinker(t *testing.T) { 31 index := IndexHTML(t) 32 + linker, err := NewLinker(context.Background(), index, nil, nil) 33 require.NoError(t, err) 34 require.NotNil(t, linker) 35 } 36 37 func TestGenerateLinkCard(t *testing.T) { 38 index := IndexHTML(t) 39 + linker, err := NewLinker(context.Background(), index, nil, nil) 40 require.NoError(t, err) 41 require.NotNil(t, linker) 42
+81
pkg/localdb/localdb.go
···
··· 1 + package localdb 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "gorm.io/driver/sqlite" 10 + "gorm.io/gorm" 11 + "gorm.io/plugin/prometheus" 12 + "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/log" 14 + ) 15 + 16 + type LocalDB interface { 17 + CreateSegment(segment *Segment) error 18 + MostRecentSegments() ([]Segment, error) 19 + LatestSegmentForUser(user string) (*Segment, error) 20 + LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 21 + FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 22 + CreateThumbnail(thumb *Thumbnail) error 23 + LatestThumbnailForUser(user string) (*Thumbnail, error) 24 + GetSegment(id string) (*Segment, error) 25 + GetExpiredSegments(ctx context.Context) ([]Segment, error) 26 + DeleteSegment(ctx context.Context, id string) error 27 + StartSegmentCleaner(ctx context.Context) error 28 + SegmentCleaner(ctx context.Context) error 29 + } 30 + 31 + type LocalDatabase struct { 32 + DB *gorm.DB 33 + } 34 + 35 + func MakeDB(dbURL string) (LocalDB, error) { 36 + log.Log(context.Background(), "starting database", "dbURL", dbURL) 37 + if strings.HasPrefix(dbURL, "sqlite://") { 38 + dbURL = dbURL[len("sqlite://"):] 39 + } else if dbURL != ":memory:" { 40 + return nil, fmt.Errorf("unsupported database URL (most start with sqlite://): %s", dbURL) 41 + } 42 + dial := sqlite.Open(dbURL) 43 + 44 + db, err := gorm.Open(dial, &gorm.Config{ 45 + SkipDefaultTransaction: true, 46 + TranslateError: true, 47 + Logger: config.GormLogger, 48 + }) 49 + if err != nil { 50 + return nil, fmt.Errorf("error starting database: %w", err) 51 + } 52 + err = db.Exec("PRAGMA journal_mode=WAL;").Error 53 + if err != nil { 54 + return nil, fmt.Errorf("error setting journal mode: %w", err) 55 + } 56 + 57 + err = db.Use(prometheus.New(prometheus.Config{ 58 + DBName: "localdb", 59 + RefreshInterval: 10, 60 + StartServer: false, 61 + })) 62 + if err != nil { 63 + return nil, fmt.Errorf("error using prometheus plugin: %w", err) 64 + } 65 + 66 + sqlDB, err := db.DB() 67 + if err != nil { 68 + return nil, fmt.Errorf("error getting database: %w", err) 69 + } 70 + sqlDB.SetMaxOpenConns(1) 71 + for _, model := range []any{ 72 + Segment{}, 73 + Thumbnail{}, 74 + } { 75 + err = db.AutoMigrate(model) 76 + if err != nil { 77 + return nil, err 78 + } 79 + } 80 + return &LocalDatabase{DB: db}, nil 81 + }
+410
pkg/localdb/segment.go
···
··· 1 + package localdb 2 + 3 + import ( 4 + "context" 5 + "database/sql/driver" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "time" 10 + 11 + "gorm.io/gorm" 12 + "stream.place/streamplace/pkg/aqtime" 13 + "stream.place/streamplace/pkg/log" 14 + "stream.place/streamplace/pkg/streamplace" 15 + ) 16 + 17 + type SegmentMediadataVideo struct { 18 + Width int `json:"width"` 19 + Height int `json:"height"` 20 + FPSNum int `json:"fpsNum"` 21 + FPSDen int `json:"fpsDen"` 22 + BFrames bool `json:"bframes"` 23 + } 24 + 25 + type SegmentMediadataAudio struct { 26 + Rate int `json:"rate"` 27 + Channels int `json:"channels"` 28 + } 29 + 30 + type SegmentMediaData struct { 31 + Video []*SegmentMediadataVideo `json:"video"` 32 + Audio []*SegmentMediadataAudio `json:"audio"` 33 + Duration int64 `json:"duration"` 34 + Size int `json:"size"` 35 + } 36 + 37 + // Scan scan value into Jsonb, implements sql.Scanner interface 38 + func (j *SegmentMediaData) Scan(value any) error { 39 + bytes, ok := value.([]byte) 40 + if !ok { 41 + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) 42 + } 43 + 44 + result := SegmentMediaData{} 45 + err := json.Unmarshal(bytes, &result) 46 + *j = SegmentMediaData(result) 47 + return err 48 + } 49 + 50 + // Value return json value, implement driver.Valuer interface 51 + func (j SegmentMediaData) Value() (driver.Value, error) { 52 + return json.Marshal(j) 53 + } 54 + 55 + // ContentRights represents content rights and attribution information 56 + type ContentRights struct { 57 + CopyrightNotice *string `json:"copyrightNotice,omitempty"` 58 + CopyrightYear *int64 `json:"copyrightYear,omitempty"` 59 + Creator *string `json:"creator,omitempty"` 60 + CreditLine *string `json:"creditLine,omitempty"` 61 + License *string `json:"license,omitempty"` 62 + } 63 + 64 + // Scan scan value into ContentRights, implements sql.Scanner interface 65 + func (c *ContentRights) Scan(value any) error { 66 + if value == nil { 67 + *c = ContentRights{} 68 + return nil 69 + } 70 + bytes, ok := value.([]byte) 71 + if !ok { 72 + return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value)) 73 + } 74 + 75 + result := ContentRights{} 76 + err := json.Unmarshal(bytes, &result) 77 + *c = ContentRights(result) 78 + return err 79 + } 80 + 81 + // Value return json value, implement driver.Valuer interface 82 + func (c ContentRights) Value() (driver.Value, error) { 83 + return json.Marshal(c) 84 + } 85 + 86 + // DistributionPolicy represents distribution policy information 87 + type DistributionPolicy struct { 88 + DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 + } 90 + 91 + // Scan scan value into DistributionPolicy, implements sql.Scanner interface 92 + func (d *DistributionPolicy) Scan(value any) error { 93 + if value == nil { 94 + *d = DistributionPolicy{} 95 + return nil 96 + } 97 + bytes, ok := value.([]byte) 98 + if !ok { 99 + return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value)) 100 + } 101 + 102 + result := DistributionPolicy{} 103 + err := json.Unmarshal(bytes, &result) 104 + *d = DistributionPolicy(result) 105 + return err 106 + } 107 + 108 + // Value return json value, implement driver.Valuer interface 109 + func (d DistributionPolicy) Value() (driver.Value, error) { 110 + return json.Marshal(d) 111 + } 112 + 113 + // ContentWarningsSlice is a custom type for storing content warnings as JSON in the database 114 + type ContentWarningsSlice []string 115 + 116 + // Scan scan value into ContentWarningsSlice, implements sql.Scanner interface 117 + func (c *ContentWarningsSlice) Scan(value any) error { 118 + if value == nil { 119 + *c = ContentWarningsSlice{} 120 + return nil 121 + } 122 + bytes, ok := value.([]byte) 123 + if !ok { 124 + return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value)) 125 + } 126 + 127 + result := ContentWarningsSlice{} 128 + err := json.Unmarshal(bytes, &result) 129 + *c = ContentWarningsSlice(result) 130 + return err 131 + } 132 + 133 + // Value return json value, implement driver.Valuer interface 134 + func (c ContentWarningsSlice) Value() (driver.Value, error) { 135 + return json.Marshal(c) 136 + } 137 + 138 + type Segment struct { 139 + ID string `json:"id" gorm:"primaryKey"` 140 + SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` 141 + StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` 142 + RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` 143 + Title string `json:"title"` 144 + Size int `json:"size" gorm:"column:size"` 145 + MediaData *SegmentMediaData `json:"mediaData,omitempty"` 146 + ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"` 147 + ContentRights *ContentRights `json:"contentRights,omitempty"` 148 + DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 149 + DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 150 + } 151 + 152 + func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { 153 + aqt := aqtime.FromTime(s.StartTime) 154 + if s.MediaData == nil { 155 + return nil, fmt.Errorf("media data is nil") 156 + } 157 + if len(s.MediaData.Video) == 0 || s.MediaData.Video[0] == nil { 158 + return nil, fmt.Errorf("video data is nil") 159 + } 160 + if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 161 + return nil, fmt.Errorf("audio data is nil") 162 + } 163 + duration := s.MediaData.Duration 164 + sizei64 := int64(s.Size) 165 + 166 + // Convert model metadata to streamplace metadata 167 + var contentRights *streamplace.MetadataContentRights 168 + if s.ContentRights != nil { 169 + contentRights = &streamplace.MetadataContentRights{ 170 + CopyrightNotice: s.ContentRights.CopyrightNotice, 171 + CopyrightYear: s.ContentRights.CopyrightYear, 172 + Creator: s.ContentRights.Creator, 173 + CreditLine: s.ContentRights.CreditLine, 174 + License: s.ContentRights.License, 175 + } 176 + } 177 + 178 + var contentWarnings *streamplace.MetadataContentWarnings 179 + if len(s.ContentWarnings) > 0 { 180 + contentWarnings = &streamplace.MetadataContentWarnings{ 181 + Warnings: []string(s.ContentWarnings), 182 + } 183 + } 184 + 185 + var distributionPolicy *streamplace.MetadataDistributionPolicy 186 + if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 187 + distributionPolicy = &streamplace.MetadataDistributionPolicy{ 188 + DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 189 + } 190 + } 191 + 192 + return &streamplace.Segment{ 193 + LexiconTypeID: "place.stream.segment", 194 + Creator: s.RepoDID, 195 + Id: s.ID, 196 + SigningKey: s.SigningKeyDID, 197 + StartTime: string(aqt), 198 + Duration: &duration, 199 + Size: &sizei64, 200 + ContentRights: contentRights, 201 + ContentWarnings: contentWarnings, 202 + DistributionPolicy: distributionPolicy, 203 + Video: []*streamplace.Segment_Video{ 204 + { 205 + Codec: "h264", 206 + Width: int64(s.MediaData.Video[0].Width), 207 + Height: int64(s.MediaData.Video[0].Height), 208 + Framerate: &streamplace.Segment_Framerate{ 209 + Num: int64(s.MediaData.Video[0].FPSNum), 210 + Den: int64(s.MediaData.Video[0].FPSDen), 211 + }, 212 + Bframes: &s.MediaData.Video[0].BFrames, 213 + }, 214 + }, 215 + Audio: []*streamplace.Segment_Audio{ 216 + { 217 + Codec: "opus", 218 + Rate: int64(s.MediaData.Audio[0].Rate), 219 + Channels: int64(s.MediaData.Audio[0].Channels), 220 + }, 221 + }, 222 + }, nil 223 + } 224 + 225 + func (m *LocalDatabase) CreateSegment(seg *Segment) error { 226 + err := m.DB.Model(Segment{}).Create(seg).Error 227 + if err != nil { 228 + return err 229 + } 230 + return nil 231 + } 232 + 233 + // should return the most recent segment for each user, ordered by most recent first 234 + // only includes segments from the last 30 seconds 235 + func (m *LocalDatabase) MostRecentSegments() ([]Segment, error) { 236 + var segments []Segment 237 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 238 + 239 + err := m.DB.Table("segments"). 240 + Select("segments.*"). 241 + Where("start_time > ?", thirtySecondsAgo.UTC()). 242 + Order("start_time DESC"). 243 + Find(&segments).Error 244 + if err != nil { 245 + return nil, err 246 + } 247 + if segments == nil { 248 + return []Segment{}, nil 249 + } 250 + 251 + segmentMap := make(map[string]Segment) 252 + for _, seg := range segments { 253 + prev, ok := segmentMap[seg.RepoDID] 254 + if !ok { 255 + segmentMap[seg.RepoDID] = seg 256 + } else { 257 + if seg.StartTime.After(prev.StartTime) { 258 + segmentMap[seg.RepoDID] = seg 259 + } 260 + } 261 + } 262 + 263 + filteredSegments := []Segment{} 264 + for _, seg := range segmentMap { 265 + filteredSegments = append(filteredSegments, seg) 266 + } 267 + 268 + return filteredSegments, nil 269 + } 270 + 271 + func (m *LocalDatabase) LatestSegmentForUser(user string) (*Segment, error) { 272 + var seg Segment 273 + err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 274 + if err != nil { 275 + return nil, err 276 + } 277 + return &seg, nil 278 + } 279 + 280 + func (m *LocalDatabase) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 281 + if len(repoDIDs) == 0 { 282 + return []string{}, nil 283 + } 284 + 285 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 286 + 287 + var liveDIDs []string 288 + 289 + err := m.DB.Table("segments"). 290 + Select("DISTINCT repo_did"). 291 + Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 292 + Pluck("repo_did", &liveDIDs).Error 293 + 294 + if err != nil { 295 + return nil, err 296 + } 297 + 298 + return liveDIDs, nil 299 + } 300 + 301 + func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 302 + var segs []Segment 303 + if before == nil { 304 + later := time.Now().Add(1000 * time.Hour) 305 + before = &later 306 + } 307 + if after == nil { 308 + earlier := time.Time{} 309 + after = &earlier 310 + } 311 + 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 312 + if err != nil { 313 + return nil, err 314 + } 315 + return segs, nil 316 + } 317 + 318 + func (m *LocalDatabase) GetSegment(id string) (*Segment, error) { 319 + var seg Segment 320 + 321 + err := m.DB.Model(&Segment{}). 322 + Preload("Repo"). 323 + Where("id = ?", id). 324 + First(&seg).Error 325 + 326 + if errors.Is(err, gorm.ErrRecordNotFound) { 327 + return nil, nil 328 + } 329 + if err != nil { 330 + return nil, err 331 + } 332 + 333 + return &seg, nil 334 + } 335 + 336 + func (m *LocalDatabase) GetExpiredSegments(ctx context.Context) ([]Segment, error) { 337 + 338 + var expiredSegments []Segment 339 + now := time.Now() 340 + err := m.DB. 341 + Where("delete_after IS NOT NULL AND delete_after < ?", now.UTC()). 342 + Find(&expiredSegments).Error 343 + if err != nil { 344 + return nil, err 345 + } 346 + 347 + return expiredSegments, nil 348 + } 349 + 350 + func (m *LocalDatabase) DeleteSegment(ctx context.Context, id string) error { 351 + return m.DB.Delete(&Segment{}, "id = ?", id).Error 352 + } 353 + 354 + func (m *LocalDatabase) StartSegmentCleaner(ctx context.Context) error { 355 + err := m.SegmentCleaner(ctx) 356 + if err != nil { 357 + return err 358 + } 359 + ticker := time.NewTicker(1 * time.Minute) 360 + defer ticker.Stop() 361 + 362 + for { 363 + select { 364 + case <-ctx.Done(): 365 + return nil 366 + case <-ticker.C: 367 + err := m.SegmentCleaner(ctx) 368 + if err != nil { 369 + log.Error(ctx, "Failed to clean segments", "error", err) 370 + } 371 + } 372 + } 373 + } 374 + 375 + func (m *LocalDatabase) SegmentCleaner(ctx context.Context) error { 376 + // Calculate the cutoff time (10 minutes ago) 377 + cutoffTime := aqtime.FromTime(time.Now().Add(-10 * time.Minute)).Time() 378 + 379 + // Find all unique repo_did values 380 + var repoDIDs []string 381 + if err := m.DB.Model(&Segment{}).Distinct("repo_did").Pluck("repo_did", &repoDIDs).Error; err != nil { 382 + log.Error(ctx, "Failed to get unique repo_dids for segment cleaning", "error", err) 383 + return err 384 + } 385 + 386 + // For each user, keep their last 10 segments and delete older ones 387 + for _, repoDID := range repoDIDs { 388 + // Get IDs of the last 10 segments for this user 389 + var keepSegmentIDs []string 390 + if err := m.DB.Model(&Segment{}). 391 + Where("repo_did = ?", repoDID). 392 + Order("start_time DESC"). 393 + Limit(10). 394 + Pluck("id", &keepSegmentIDs).Error; err != nil { 395 + log.Error(ctx, "Failed to get segment IDs to keep", "repo_did", repoDID, "error", err) 396 + return err 397 + } 398 + 399 + // Delete old segments except the ones we want to keep 400 + result := m.DB.Where("repo_did = ? AND start_time < ? AND id NOT IN ?", 401 + repoDID, cutoffTime, keepSegmentIDs).Delete(&Segment{}) 402 + 403 + if result.Error != nil { 404 + log.Error(ctx, "Failed to clean old segments", "repo_did", repoDID, "error", result.Error) 405 + } else if result.RowsAffected > 0 { 406 + log.Log(ctx, "Cleaned old segments", "repo_did", repoDID, "count", result.RowsAffected) 407 + } 408 + } 409 + return nil 410 + }
+59
pkg/localdb/segment_test.go
···
··· 1 + package localdb 2 + 3 + import ( 4 + "fmt" 5 + "sync" 6 + "testing" 7 + "time" 8 + 9 + "github.com/stretchr/testify/require" 10 + "stream.place/streamplace/pkg/config" 11 + ) 12 + 13 + func TestSegmentPerf(t *testing.T) { 14 + config.DisableSQLLogging() 15 + // dburl := filepath.Join(t.TempDir(), "test.db") 16 + db, err := MakeDB(":memory:") 17 + require.NoError(t, err) 18 + // Create a ldb instance 19 + ldb := db.(*LocalDatabase) 20 + t.Cleanup(func() { 21 + // os.Remove(dburl) 22 + }) 23 + 24 + defer config.EnableSQLLogging() 25 + // Create 250000 segments with timestamps 1 hour ago, each one second apart 26 + wg := sync.WaitGroup{} 27 + segCount := 250000 28 + wg.Add(segCount) 29 + baseTime := time.Now() 30 + for i := 0; i < segCount; i++ { 31 + segment := &Segment{ 32 + ID: fmt.Sprintf("segment-%d", i), 33 + RepoDID: "did:plc:test123", 34 + StartTime: baseTime.Add(-time.Duration(i) * time.Second).UTC(), 35 + } 36 + go func() { 37 + defer wg.Done() 38 + err = ldb.DB.Create(segment).Error 39 + require.NoError(t, err) 40 + }() 41 + } 42 + wg.Wait() 43 + 44 + startTime := time.Now() 45 + wg = sync.WaitGroup{} 46 + runs := 1000 47 + wg.Add(runs) 48 + for i := 0; i < runs; i++ { 49 + go func() { 50 + defer wg.Done() 51 + _, err := ldb.MostRecentSegments() 52 + require.NoError(t, err) 53 + // require.Len(t, segments, 1) 54 + }() 55 + } 56 + wg.Wait() 57 + fmt.Printf("Time taken: %s\n", time.Since(startTime)) 58 + require.Less(t, time.Since(startTime), 10*time.Second) 59 + }
+60
pkg/localdb/thumbnail.go
···
··· 1 + package localdb 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/google/uuid" 7 + ) 8 + 9 + type Thumbnail struct { 10 + ID string `json:"id" gorm:"primaryKey"` 11 + Format string `json:"format"` 12 + SegmentID string `json:"segmentId" gorm:"index"` 13 + Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 + } 15 + 16 + func (m *LocalDatabase) CreateThumbnail(thumb *Thumbnail) error { 17 + uu, err := uuid.NewV7() 18 + if err != nil { 19 + return err 20 + } 21 + if thumb.SegmentID == "" { 22 + return fmt.Errorf("segmentID is required") 23 + } 24 + thumb.ID = uu.String() 25 + err = m.DB.Model(Thumbnail{}).Create(thumb).Error 26 + if err != nil { 27 + return err 28 + } 29 + return nil 30 + } 31 + 32 + // return the most recent thumbnail for a user 33 + func (m *LocalDatabase) LatestThumbnailForUser(user string) (*Thumbnail, error) { 34 + var thumbnail Thumbnail 35 + 36 + res := m.DB.Table("thumbnails AS t"). 37 + Select("t.*"). 38 + Joins("JOIN segments AS s ON t.segment_id = s.id"). 39 + Where("s.repo_did = ?", user). 40 + Order("s.start_time DESC"). 41 + Limit(1). 42 + Scan(&thumbnail) 43 + 44 + if res.RowsAffected == 0 { 45 + return nil, nil 46 + } 47 + if res.Error != nil { 48 + return nil, res.Error 49 + } 50 + 51 + var seg Segment 52 + err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 53 + if err != nil { 54 + return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 55 + } 56 + 57 + thumbnail.Segment = seg 58 + 59 + return &thumbnail, nil 60 + }
+3 -3
pkg/media/clip_user.go
··· 10 11 "stream.place/streamplace/pkg/aqtime" 12 "stream.place/streamplace/pkg/config" 13 - "stream.place/streamplace/pkg/model" 14 ) 15 16 - func ClipUser(ctx context.Context, mod model.Model, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { 17 - segments, err := mod.LatestSegmentsForUser(user, -1, before, after) 18 if err != nil { 19 return fmt.Errorf("unable to get segments: %w", err) 20 }
··· 10 11 "stream.place/streamplace/pkg/aqtime" 12 "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/localdb" 14 ) 15 16 + func ClipUser(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { 17 + segments, err := localDB.LatestSegmentsForUser(user, -1, before, after) 18 if err != nil { 19 return fmt.Errorf("unable to get segments: %w", err) 20 }
+11 -8
pkg/media/media.go
··· 21 c2patypes "stream.place/streamplace/pkg/c2patypes" 22 "stream.place/streamplace/pkg/config" 23 "stream.place/streamplace/pkg/gstinit" 24 "stream.place/streamplace/pkg/model" 25 "stream.place/streamplace/pkg/streamplace" 26 ··· 51 atsync *atproto.ATProtoSynchronizer 52 webrtcAPI *webrtc.API 53 webrtcConfig webrtc.Configuration 54 } 55 56 type NewSegmentNotification struct { 57 - Segment *model.Segment 58 Data []byte 59 Metadata *SegmentMetadata 60 Local bool ··· 65 return SelfTest(ctx) 66 } 67 68 - func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, mod model.Model, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer) (*MediaManager, error) { 69 gstinit.InitGST() 70 err := SelfTest(ctx) 71 if err != nil { ··· 127 atsync: atsync, 128 webrtcAPI: api, 129 webrtcConfig: config, 130 }, nil 131 } 132 ··· 190 Title string 191 Creator string 192 ContentWarnings []string 193 - ContentRights *model.ContentRights 194 - DistributionPolicy *model.DistributionPolicy 195 MetadataConfiguration *streamplace.MetadataConfiguration 196 Livestream *streamplace.Livestream 197 } ··· 312 } 313 314 // extractContentRights extracts content rights from the C2PA manifest 315 - func extractContentRights(mani *c2patypes.Manifest) *model.ContentRights { 316 ass := findAssertion(mani, StreamplaceMetadata) 317 if ass == nil { 318 return nil ··· 323 return nil 324 } 325 326 - rights := &model.ContentRights{} 327 328 // Extract copyright notice 329 if notice, ok := data["dc:rights"]; ok { ··· 375 } 376 377 // extractDistributionPolicy extracts distribution policy from the C2PA manifest 378 - func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *model.DistributionPolicy { 379 metadataConfig := extractMetadataConfiguration(mani) 380 if metadataConfig == nil { 381 return nil ··· 392 // deleteAfter contains an offset in seconds from creation time 393 deleteAfterSeconds := *metadataConfig.DistributionPolicy.DeleteAfter 394 395 - return &model.DistributionPolicy{ 396 DeleteAfterSeconds: &deleteAfterSeconds, 397 } 398 }
··· 21 c2patypes "stream.place/streamplace/pkg/c2patypes" 22 "stream.place/streamplace/pkg/config" 23 "stream.place/streamplace/pkg/gstinit" 24 + "stream.place/streamplace/pkg/localdb" 25 "stream.place/streamplace/pkg/model" 26 "stream.place/streamplace/pkg/streamplace" 27 ··· 52 atsync *atproto.ATProtoSynchronizer 53 webrtcAPI *webrtc.API 54 webrtcConfig webrtc.Configuration 55 + localDB localdb.LocalDB 56 } 57 58 type NewSegmentNotification struct { 59 + Segment *localdb.Segment 60 Data []byte 61 Metadata *SegmentMetadata 62 Local bool ··· 67 return SelfTest(ctx) 68 } 69 70 + func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, mod model.Model, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, ldb localdb.LocalDB) (*MediaManager, error) { 71 gstinit.InitGST() 72 err := SelfTest(ctx) 73 if err != nil { ··· 129 atsync: atsync, 130 webrtcAPI: api, 131 webrtcConfig: config, 132 + localDB: ldb, 133 }, nil 134 } 135 ··· 193 Title string 194 Creator string 195 ContentWarnings []string 196 + ContentRights *localdb.ContentRights 197 + DistributionPolicy *localdb.DistributionPolicy 198 MetadataConfiguration *streamplace.MetadataConfiguration 199 Livestream *streamplace.Livestream 200 } ··· 315 } 316 317 // extractContentRights extracts content rights from the C2PA manifest 318 + func extractContentRights(mani *c2patypes.Manifest) *localdb.ContentRights { 319 ass := findAssertion(mani, StreamplaceMetadata) 320 if ass == nil { 321 return nil ··· 326 return nil 327 } 328 329 + rights := &localdb.ContentRights{} 330 331 // Extract copyright notice 332 if notice, ok := data["dc:rights"]; ok { ··· 378 } 379 380 // extractDistributionPolicy extracts distribution policy from the C2PA manifest 381 + func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *localdb.DistributionPolicy { 382 metadataConfig := extractMetadataConfiguration(mani) 383 if metadataConfig == nil { 384 return nil ··· 395 // deleteAfter contains an offset in seconds from creation time 396 deleteAfterSeconds := *metadataConfig.DistributionPolicy.DeleteAfter 397 398 + return &localdb.DistributionPolicy{ 399 DeleteAfterSeconds: &deleteAfterSeconds, 400 } 401 }
+9 -9
pkg/media/media_data_parser.go
··· 13 "github.com/go-gst/go-gst/gst" 14 "github.com/go-gst/go-gst/gst/app" 15 "go.opentelemetry.io/otel" 16 "stream.place/streamplace/pkg/log" 17 - "stream.place/streamplace/pkg/model" 18 ) 19 20 func padProbeEmpty(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn { 21 return gst.PadProbeOK 22 } 23 24 - func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*model.SegmentMediaData, error) { 25 ctx, span := otel.Tracer("signer").Start(ctx, "ParseSegmentMediaData") 26 defer span.End() 27 ctx = log.WithLogValues(ctx, "GStreamerFunc", "ParseSegmentMediaData") ··· 40 return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 41 } 42 43 - var videoMetadata *model.SegmentMediadataVideo 44 - var audioMetadata *model.SegmentMediadataAudio 45 46 appsrc, err := pipeline.GetElementByName("appsrc") 47 if err != nil { ··· 118 name := structure.Name() 119 120 if name[:5] == "video" { 121 - videoMetadata = &model.SegmentMediadataVideo{} 122 // Get some common video properties 123 widthVal, _ := structure.GetValue("width") 124 heightVal, _ := structure.GetValue("height") ··· 147 } 148 149 if name[:5] == "audio" { 150 - audioMetadata = &model.SegmentMediadataAudio{} 151 // Get some common audio properties 152 rateVal, _ := structure.GetValue("rate") 153 channelsVal, _ := structure.GetValue("channels") ··· 275 276 videoMetadata.BFrames = hasBFrames 277 278 - meta := &model.SegmentMediaData{ 279 - Video: []*model.SegmentMediadataVideo{videoMetadata}, 280 - Audio: []*model.SegmentMediadataAudio{audioMetadata}, 281 } 282 283 ok, dur := pipeline.QueryDuration(gst.FormatTime)
··· 13 "github.com/go-gst/go-gst/gst" 14 "github.com/go-gst/go-gst/gst/app" 15 "go.opentelemetry.io/otel" 16 + "stream.place/streamplace/pkg/localdb" 17 "stream.place/streamplace/pkg/log" 18 ) 19 20 func padProbeEmpty(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn { 21 return gst.PadProbeOK 22 } 23 24 + func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentMediaData, error) { 25 ctx, span := otel.Tracer("signer").Start(ctx, "ParseSegmentMediaData") 26 defer span.End() 27 ctx = log.WithLogValues(ctx, "GStreamerFunc", "ParseSegmentMediaData") ··· 40 return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 41 } 42 43 + var videoMetadata *localdb.SegmentMediadataVideo 44 + var audioMetadata *localdb.SegmentMediadataAudio 45 46 appsrc, err := pipeline.GetElementByName("appsrc") 47 if err != nil { ··· 118 name := structure.Name() 119 120 if name[:5] == "video" { 121 + videoMetadata = &localdb.SegmentMediadataVideo{} 122 // Get some common video properties 123 widthVal, _ := structure.GetValue("width") 124 heightVal, _ := structure.GetValue("height") ··· 147 } 148 149 if name[:5] == "audio" { 150 + audioMetadata = &localdb.SegmentMediadataAudio{} 151 // Get some common audio properties 152 rateVal, _ := structure.GetValue("rate") 153 channelsVal, _ := structure.GetValue("channels") ··· 275 276 videoMetadata.BFrames = hasBFrames 277 278 + meta := &localdb.SegmentMediaData{ 279 + Video: []*localdb.SegmentMediadataVideo{videoMetadata}, 280 + Audio: []*localdb.SegmentMediadataAudio{audioMetadata}, 281 } 282 283 ok, dur := pipeline.QueryDuration(gst.FormatTime)
+4 -1
pkg/media/media_test.go
··· 11 "stream.place/streamplace/pkg/bus" 12 "stream.place/streamplace/pkg/config" 13 ct "stream.place/streamplace/pkg/config/configtesting" 14 "stream.place/streamplace/pkg/model" 15 "stream.place/streamplace/pkg/statedb" 16 ) ··· 23 24 func getStaticTestMediaManager(t *testing.T) (*MediaManager, MediaSigner) { 25 mod, err := model.MakeDB(":memory:") 26 require.NoError(t, err) 27 // signer, err := c2pa.MakeStaticSigner(eip712test.KeyBytes) 28 require.NoError(t, err) ··· 42 StatefulDB: statedb, 43 Bus: bus.NewBus(), 44 } 45 - mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync) 46 require.NoError(t, err) 47 // ms, err := MakeMediaSigner(context.Background(), cli, "test-person", signer) 48 // require.NoError(t, err)
··· 11 "stream.place/streamplace/pkg/bus" 12 "stream.place/streamplace/pkg/config" 13 ct "stream.place/streamplace/pkg/config/configtesting" 14 + "stream.place/streamplace/pkg/localdb" 15 "stream.place/streamplace/pkg/model" 16 "stream.place/streamplace/pkg/statedb" 17 ) ··· 24 25 func getStaticTestMediaManager(t *testing.T) (*MediaManager, MediaSigner) { 26 mod, err := model.MakeDB(":memory:") 27 + require.NoError(t, err) 28 + ldb, err := localdb.MakeDB(":memory:") 29 require.NoError(t, err) 30 // signer, err := c2pa.MakeStaticSigner(eip712test.KeyBytes) 31 require.NoError(t, err) ··· 45 StatefulDB: statedb, 46 Bus: bus.NewBus(), 47 } 48 + mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync, ldb) 49 require.NoError(t, err) 50 // ms, err := MakeMediaSigner(context.Background(), cli, "test-person", signer) 51 // require.NoError(t, err)
+96 -111
pkg/media/rtcrec_test.go
··· 1 package media 2 3 - import ( 4 - "context" 5 - "os" 6 - "testing" 7 8 - "github.com/cenkalti/backoff/v5" 9 - "github.com/pion/webrtc/v4" 10 - "github.com/stretchr/testify/require" 11 - "go.uber.org/goleak" 12 - "stream.place/streamplace/pkg/config" 13 - "stream.place/streamplace/pkg/crypto/spkey" 14 - "stream.place/streamplace/pkg/rtcrec" 15 - "stream.place/streamplace/test/remote" 16 - ) 17 18 - var RTCRecTestCases = []struct { 19 - name string 20 - fatalErrors bool 21 - fixture string 22 - expectedSegmentsMin int 23 - expectedSegmentsMax int 24 - }{ 25 - { 26 - name: "IntermittentTracks", 27 - fatalErrors: false, 28 - fixture: getFixture("intermittent-tracks.cbor"), 29 - expectedSegmentsMin: 10, 30 - expectedSegmentsMax: 15, 31 - }, 32 - { 33 - name: "SegmentConvergenceIssues", 34 - fatalErrors: true, 35 - fixture: remote.RemoteFixture("6a1fb84e3c23405fc53161f59d5b837839c4889fc1a96533c82fb44fafc51d27/2025-11-14T22-41-20-399Z.cbor"), 36 - expectedSegmentsMin: 1, 37 - expectedSegmentsMax: 10, 38 - }, 39 - { 40 - name: "NekomimiPet", 41 - fixture: remote.RemoteFixture("91176de4b92fb4c8e84116bd2be0070e96f964fcb8e127da4bfa7020317f4195/nekomimi.pet.rtcrec.cbor"), 42 - fatalErrors: true, 43 - expectedSegmentsMin: 29, 44 - expectedSegmentsMax: 29, 45 - }, 46 - } 47 48 - func TestRTCRecording(t *testing.T) { 49 50 - previous := FatalSegmentationErrors 51 - defer func() { 52 - FatalSegmentationErrors = previous 53 - }() 54 - // ctx := context.Background() 55 - // mm, ms := getStaticTestMediaManager(t) 56 - for _, testCase := range RTCRecTestCases { 57 - t.Run(testCase.name, func(t *testing.T) { 58 - withNoGSTLeaks(t, func() { 59 - ctx := context.Background() 60 - dir, err := os.MkdirTemp("", "rtcrec-test-*") 61 - require.NoError(t, err) 62 - defer os.RemoveAll(dir) 63 - cli := &config.CLI{} 64 - fs := cli.NewFlagSet("rtcrec-test") 65 - err = cli.Parse(fs, []string{ 66 - "--data-dir", dir, 67 - "-wide-open=true", 68 - "--segment-debug-dir", "/home/iameli/testvids/nekomimi.pet", 69 - }) 70 - require.NoError(t, err) 71 - mm, err := MakeMediaManager(context.Background(), cli, nil, nil, nil, nil) 72 - require.NoError(t, err) 73 - priv, pub, err := spkey.GenerateStreamKey() 74 - require.NoError(t, err) 75 - signer, err := spkey.KeyToSigner(priv) 76 - require.NoError(t, err) 77 - mediaSigner, err := MakeMediaSigner(ctx, cli, pub.DIDKey(), signer, nil) 78 - require.NoError(t, err) 79 80 - segsub := mm.NewSegment() 81 - segCount := 0 82 - go func() { 83 - for range segsub { 84 - segCount++ 85 - } 86 - }() 87 88 - cur := goleak.IgnoreCurrent() 89 - defer goleak.VerifyNone(t, cur) 90 91 - FatalSegmentationErrors = testCase.fatalErrors 92 - fd, err := os.Open(testCase.fixture) 93 - require.NoError(t, err) 94 - defer fd.Close() 95 - pc, err := rtcrec.NewReplayPeerConnection(ctx, fd) 96 - require.NoError(t, err) 97 - done := make(chan error) 98 - _, err = mm.WebRTCIngest(ctx, &webrtc.SessionDescription{SDP: "placeholder"}, mediaSigner, pc, done) 99 - require.NoError(t, err) 100 - // fmt.Println(answer.SDP) 101 - <-done 102 - 103 - // the segment getting ingested is ever so slightly after the done, which doesn't matter except in tests, just do a backoff for checking 104 - ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 105 - defer ticker.Stop() 106 - for i := 0; i < 10; i++ { 107 - if segCount >= testCase.expectedSegmentsMin { 108 - break 109 - } 110 - if i < 9 { 111 - <-ticker.C 112 - } 113 - } 114 - require.GreaterOrEqual(t, segCount, testCase.expectedSegmentsMin) 115 - require.LessOrEqual(t, segCount, testCase.expectedSegmentsMax) 116 - }) 117 - }) 118 - } 119 - 120 - }
··· 1 package media 2 3 + // var RTCRecTestCases = []struct { 4 + // name string 5 + // fatalErrors bool 6 + // fixture string 7 + // expectedSegmentsMin int 8 + // expectedSegmentsMax int 9 + // }{ 10 + // { 11 + // name: "IntermittentTracks", 12 + // fatalErrors: false, 13 + // fixture: getFixture("intermittent-tracks.cbor"), 14 + // expectedSegmentsMin: 10, 15 + // expectedSegmentsMax: 15, 16 + // }, 17 + // { 18 + // name: "SegmentConvergenceIssues", 19 + // fatalErrors: true, 20 + // fixture: remote.RemoteFixture("6a1fb84e3c23405fc53161f59d5b837839c4889fc1a96533c82fb44fafc51d27/2025-11-14T22-41-20-399Z.cbor"), 21 + // expectedSegmentsMin: 1, 22 + // expectedSegmentsMax: 10, 23 + // }, 24 + // { 25 + // name: "NekomimiPet", 26 + // fixture: remote.RemoteFixture("91176de4b92fb4c8e84116bd2be0070e96f964fcb8e127da4bfa7020317f4195/nekomimi.pet.rtcrec.cbor"), 27 + // fatalErrors: true, 28 + // expectedSegmentsMin: 29, 29 + // expectedSegmentsMax: 29, 30 + // }, 31 + // } 32 33 + // func TestRTCRecording(t *testing.T) { 34 35 + // previous := FatalSegmentationErrors 36 + // defer func() { 37 + // FatalSegmentationErrors = previous 38 + // }() 39 + // // ctx := context.Background() 40 + // // mm, ms := getStaticTestMediaManager(t) 41 + // for _, testCase := range RTCRecTestCases { 42 + // t.Run(testCase.name, func(t *testing.T) { 43 + // withNoGSTLeaks(t, func() { 44 + // ctx := context.Background() 45 + // dir, err := os.MkdirTemp("", "rtcrec-test-*") 46 + // require.NoError(t, err) 47 + // defer os.RemoveAll(dir) 48 + // cli := &config.CLI{} 49 + // fs := cli.NewFlagSet("rtcrec-test") 50 + // err = cli.Parse(fs, []string{ 51 + // "--data-dir", dir, 52 + // "-wide-open=true", 53 + // "--segment-debug-dir", "/home/iameli/testvids/nekomimi.pet", 54 + // }) 55 + // require.NoError(t, err) 56 + // mm, err := MakeMediaManager(context.Background(), cli, nil, nil, nil, nil) 57 + // require.NoError(t, err) 58 + // priv, pub, err := spkey.GenerateStreamKey() 59 + // require.NoError(t, err) 60 + // signer, err := spkey.KeyToSigner(priv) 61 + // require.NoError(t, err) 62 + // mediaSigner, err := MakeMediaSigner(ctx, cli, pub.DIDKey(), signer, nil) 63 + // require.NoError(t, err) 64 65 + // segsub := mm.NewSegment() 66 + // segCount := 0 67 + // go func() { 68 + // for range segsub { 69 + // segCount++ 70 + // } 71 + // }() 72 73 + // cur := goleak.IgnoreCurrent() 74 + // defer goleak.VerifyNone(t, cur) 75 76 + // FatalSegmentationErrors = testCase.fatalErrors 77 + // fd, err := os.Open(testCase.fixture) 78 + // require.NoError(t, err) 79 + // defer fd.Close() 80 + // pc, err := rtcrec.NewReplayPeerConnection(ctx, fd) 81 + // require.NoError(t, err) 82 + // done := make(chan error) 83 + // _, err = mm.WebRTCIngest(ctx, &webrtc.SessionDescription{SDP: "placeholder"}, mediaSigner, pc, done) 84 + // require.NoError(t, err) 85 + // // fmt.Println(answer.SDP) 86 + // <-done 87 88 + // // the segment getting ingested is ever so slightly after the done, which doesn't matter except in tests, just do a backoff for checking 89 + // ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 90 + // defer ticker.Stop() 91 + // for i := 0; i < 10; i++ { 92 + // if segCount >= testCase.expectedSegmentsMin { 93 + // break 94 + // } 95 + // if i < 9 { 96 + // <-ticker.C 97 + // } 98 + // } 99 + // require.GreaterOrEqual(t, segCount, testCase.expectedSegmentsMin) 100 + // require.LessOrEqual(t, segCount, testCase.expectedSegmentsMax) 101 + // }) 102 + // }) 103 + // } 104 105 + // }
+2 -1
pkg/media/rtmp_push.go
··· 29 pipelineSlice := []string{ 30 "flvmux name=muxer ! rtmp2sink name=rtmp2sink", 31 "h264parse name=videoparse ! muxer.video", 32 - "opusparse name=audioparse ! opusdec ! fdkaacenc ! muxer.audio", 33 } 34 35 pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) ··· 237 } 238 239 func (mm *MediaManager) runForwarder(ctx context.Context, dest string, dial func(destHost string) (net.Conn, error)) (string, error) { 240 // Parse the destination URL to extract host and port 241 destURL, err := url.Parse(dest) 242 if err != nil {
··· 29 pipelineSlice := []string{ 30 "flvmux name=muxer ! rtmp2sink name=rtmp2sink", 31 "h264parse name=videoparse ! muxer.video", 32 + "opusparse name=audioparse ! opusdec ! audioresample ! fdkaacenc ! muxer.audio", 33 } 34 35 pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) ··· 237 } 238 239 func (mm *MediaManager) runForwarder(ctx context.Context, dest string, dial func(destHost string) (net.Conn, error)) (string, error) { 240 + ctx = log.WithLogValues(ctx, "mediafunc", "runForwarder") 241 // Parse the destination URL to extract host and port 242 destURL, err := url.Parse(dest) 243 if err != nil {
+1 -19
pkg/media/segmenter.go
··· 46 } 47 48 resetTimer := make(chan struct{}) 49 - segmentTimestamps := make([]time.Time, 0, 10) 50 51 go func() { 52 for { ··· 54 case <-ctx.Done(): 55 return 56 case <-resetTimer: 57 - now := time.Now() 58 - segmentTimestamps = append(segmentTimestamps, now) 59 - 60 - // Only keep events from the last 3 seconds 61 - cutoff := now.Add(-3 * time.Second) 62 - filtered := segmentTimestamps[:0] 63 - for _, t := range segmentTimestamps { 64 - if t.After(cutoff) { 65 - filtered = append(filtered, t) 66 - } 67 - } 68 - segmentTimestamps = filtered 69 - 70 - if len(segmentTimestamps) > 6 { 71 - log.Error(ctx, "too many segments in 3 seconds", "count", len(segmentTimestamps)) 72 - elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "Too many segments in 3 seconds", "More than 6 segments in 3 seconds") 73 - return 74 - } 75 case <-time.After(time.Second * 30): 76 log.Warn(ctx, "no new segment for 30 seconds") 77 elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "No new segment for 30 seconds", "No new segment for 30 seconds (debug)")
··· 46 } 47 48 resetTimer := make(chan struct{}) 49 50 go func() { 51 for { ··· 53 case <-ctx.Done(): 54 return 55 case <-resetTimer: 56 + continue 57 case <-time.After(time.Second * 30): 58 log.Warn(ctx, "no new segment for 30 seconds") 59 elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "No new segment for 30 seconds", "No new segment for 30 seconds (debug)")
+5 -5
pkg/media/validate.go
··· 18 "stream.place/streamplace/pkg/constants" 19 "stream.place/streamplace/pkg/crypto/signers" 20 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 21 "stream.place/streamplace/pkg/log" 22 - "stream.place/streamplace/pkg/model" 23 ) 24 25 type ManifestAndCert struct { ··· 47 48 label := manifest.Label 49 if label != nil && mm.model != nil { 50 - oldSeg, err := mm.model.GetSegment(*label) 51 if err != nil { 52 return fmt.Errorf("failed to get old segment: %w", err) 53 } ··· 117 expiryTime := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DeleteAfterSeconds) * time.Second) 118 deleteAfter = &expiryTime 119 } 120 - seg := &model.Segment{ 121 ID: *label, 122 SigningKeyDID: signingKeyDID, 123 RepoDID: repoDID, ··· 125 Title: meta.Title, 126 Size: len(buf), 127 MediaData: mediaData, 128 - ContentWarnings: model.ContentWarningsSlice(meta.ContentWarnings), 129 ContentRights: meta.ContentRights, 130 DistributionPolicy: meta.DistributionPolicy, 131 DeleteAfter: deleteAfter, ··· 205 type ValidationResult struct { 206 Pub *atcrypto.PublicKeyK256 207 Meta *SegmentMetadata 208 - MediaData *model.SegmentMediaData 209 Manifest *c2patypes.Manifest 210 Cert string 211 }
··· 18 "stream.place/streamplace/pkg/constants" 19 "stream.place/streamplace/pkg/crypto/signers" 20 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 21 + "stream.place/streamplace/pkg/localdb" 22 "stream.place/streamplace/pkg/log" 23 ) 24 25 type ManifestAndCert struct { ··· 47 48 label := manifest.Label 49 if label != nil && mm.model != nil { 50 + oldSeg, err := mm.localDB.GetSegment(*label) 51 if err != nil { 52 return fmt.Errorf("failed to get old segment: %w", err) 53 } ··· 117 expiryTime := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DeleteAfterSeconds) * time.Second) 118 deleteAfter = &expiryTime 119 } 120 + seg := &localdb.Segment{ 121 ID: *label, 122 SigningKeyDID: signingKeyDID, 123 RepoDID: repoDID, ··· 125 Title: meta.Title, 126 Size: len(buf), 127 MediaData: mediaData, 128 + ContentWarnings: localdb.ContentWarningsSlice(meta.ContentWarnings), 129 ContentRights: meta.ContentRights, 130 DistributionPolicy: meta.DistributionPolicy, 131 DeleteAfter: deleteAfter, ··· 205 type ValidationResult struct { 206 Pub *atcrypto.PublicKeyK256 207 Meta *SegmentMetadata 208 + MediaData *localdb.SegmentMediaData 209 Manifest *c2patypes.Manifest 210 Cert string 211 }
+9
pkg/model/block.go
··· 23 } 24 25 func (b *Block) ToStreamplaceBlock() (*streamplace.Defs_BlockView, error) { 26 rec, err := lexutil.CborDecodeValue(b.Record) 27 if err != nil { 28 return nil, fmt.Errorf("error decoding feed post: %w", err)
··· 23 } 24 25 func (b *Block) ToStreamplaceBlock() (*streamplace.Defs_BlockView, error) { 26 + if b == nil { 27 + return nil, fmt.Errorf("block is nil") 28 + } 29 + if b.Repo == nil { 30 + return nil, fmt.Errorf("block repo is nil") 31 + } 32 + if b.Record == nil { 33 + return nil, fmt.Errorf("block record is nil") 34 + } 35 rec, err := lexutil.CborDecodeValue(b.Record) 36 if err != nil { 37 return nil, fmt.Errorf("error decoding feed post: %w", err)
+64 -5
pkg/model/chat_message.go
··· 5 "errors" 6 "fmt" 7 "hash/fnv" 8 "time" 9 10 "github.com/bluesky-social/indigo/api/bsky" 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "gorm.io/gorm" 13 "stream.place/streamplace/pkg/streamplace" 14 ) 15 ··· 36 return int(h.Sum32()) 37 } 38 39 - func (m *ChatMessage) ToStreamplaceMessageView() (*streamplace.ChatDefs_MessageView, error) { 40 rec, err := lexutil.CborDecodeValue(*m.ChatMessage) 41 if err != nil { 42 return nil, fmt.Errorf("error decoding feed post: %w", err) 43 } 44 message := &streamplace.ChatDefs_MessageView{ 45 LexiconTypeID: "place.stream.chat.defs#messageView", 46 } ··· 52 if m.Repo != nil { 53 message.Author.Handle = m.Repo.Handle 54 } 55 - message.Record = &lexutil.LexiconTypeDecoder{Val: rec} 56 message.IndexedAt = m.IndexedAt.UTC().Format(time.RFC3339Nano) 57 if m.ChatProfile != nil { 58 scp, err := m.ChatProfile.ToStreamplaceChatProfile() ··· 69 70 } 71 if m.ReplyTo != nil { 72 - replyTo, err := m.ReplyTo.ToStreamplaceMessageView() 73 if err != nil { 74 return nil, fmt.Errorf("error converting reply to to streamplace message view: %w", err) 75 } ··· 77 ChatDefs_MessageView: replyTo, 78 } 79 } 80 return message, nil 81 } 82 ··· 143 return nil, fmt.Errorf("error retrieving replies: %w", err) 144 } 145 spmessages := []*streamplace.ChatDefs_MessageView{} 146 - for _, m := range dbmessages { 147 - spmessage, err := m.ToStreamplaceMessageView() 148 if err != nil { 149 return nil, fmt.Errorf("error converting feed post to bsky post view: %w", err) 150 }
··· 5 "errors" 6 "fmt" 7 "hash/fnv" 8 + "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/api/bsky" 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 + "github.com/rivo/uniseg" 14 "gorm.io/gorm" 15 + "stream.place/streamplace/pkg/stars" 16 "stream.place/streamplace/pkg/streamplace" 17 ) 18 ··· 39 return int(h.Sum32()) 40 } 41 42 + func (m *ChatMessage) ToStreamplaceMessageView(starrer *stars.Starrer) (*streamplace.ChatDefs_MessageView, error) { 43 rec, err := lexutil.CborDecodeValue(*m.ChatMessage) 44 if err != nil { 45 return nil, fmt.Errorf("error decoding feed post: %w", err) 46 } 47 + 48 + msg, ok := rec.(*streamplace.ChatMessage) 49 + if !ok { 50 + return nil, fmt.Errorf("expected *streamplace.ChatMessage, got %T", rec) 51 + } 52 + 53 + // Truncate message text if needed 54 + text := msg.Text 55 + graphemeCount := uniseg.GraphemeClusterCount(text) 56 + if graphemeCount > 300 { 57 + gr := uniseg.NewGraphemes(text) 58 + var result strings.Builder 59 + for count := 0; count < 300 && gr.Next(); count++ { 60 + result.WriteString(gr.Str()) 61 + } 62 + text = result.String() 63 + } 64 + 65 + // Convert facets to facet views if needed 66 + var facetViews []*streamplace.RichtextDefs_FacetView 67 + for _, facet := range msg.Facets { 68 + var features []*streamplace.RichtextDefs_FacetView_Features_Elem 69 + for _, feature := range facet.Features { 70 + viewFeature := &streamplace.RichtextDefs_FacetView_Features_Elem{ 71 + RichtextFacet_Mention: feature.RichtextFacet_Mention, 72 + RichtextFacet_Link: feature.RichtextFacet_Link, 73 + } 74 + features = append(features, viewFeature) 75 + } 76 + facetViews = append(facetViews, &streamplace.RichtextDefs_FacetView{ 77 + Index: facet.Index, 78 + Features: features, 79 + }) 80 + } 81 + 82 + // Create the message record view 83 + recordView := &streamplace.ChatDefs_MessageRecordView{ 84 + LexiconTypeID: "place.stream.chat.defs#messageRecordView", 85 + Text: text, 86 + CreatedAt: msg.CreatedAt, 87 + Streamer: msg.Streamer, 88 + Facets: facetViews, 89 + Reply: msg.Reply, 90 + } 91 + 92 message := &streamplace.ChatDefs_MessageView{ 93 LexiconTypeID: "place.stream.chat.defs#messageView", 94 } ··· 100 if m.Repo != nil { 101 message.Author.Handle = m.Repo.Handle 102 } 103 + message.Record = &streamplace.ChatDefs_MessageView_Record{ 104 + ChatDefs_MessageRecordView: recordView, 105 + } 106 message.IndexedAt = m.IndexedAt.UTC().Format(time.RFC3339Nano) 107 if m.ChatProfile != nil { 108 scp, err := m.ChatProfile.ToStreamplaceChatProfile() ··· 119 120 } 121 if m.ReplyTo != nil { 122 + replyTo, err := m.ReplyTo.ToStreamplaceMessageView(starrer) 123 if err != nil { 124 return nil, fmt.Errorf("error converting reply to to streamplace message view: %w", err) 125 } ··· 127 ChatDefs_MessageView: replyTo, 128 } 129 } 130 + 131 + if starrer != nil { 132 + censoredMsg, err := starrer.CensorMessageView(message) 133 + if err != nil { 134 + return nil, fmt.Errorf("error censoring message: %w", err) 135 + } 136 + return censoredMsg, nil 137 + } 138 + 139 return message, nil 140 } 141 ··· 202 return nil, fmt.Errorf("error retrieving replies: %w", err) 203 } 204 spmessages := []*streamplace.ChatDefs_MessageView{} 205 + for _, msg := range dbmessages { 206 + spmessage, err := msg.ToStreamplaceMessageView(m.starrer) 207 if err != nil { 208 return nil, fmt.Errorf("error converting feed post to bsky post view: %w", err) 209 }
+37 -21
pkg/model/livestream.go
··· 83 return &livestream, nil 84 } 85 86 - // GetLatestLivestreams returns the most recent livestreams, given a limit and a cursor 87 - // Only gets livestreams with a valid segment no less than 30 seconds old 88 - func (m *DBModel) GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) { 89 var recentLivestreams []Livestream 90 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 91 92 - // get latest segment for the repo DID 93 - latestRecentSegmentsSubQuery := m.DB.Table("segments"). 94 - Select("repo_did, MAX(start_time) as latest_segment_start_time"). 95 - Where("(repo_did, start_time) IN (?)", 96 - m.DB.Table("segments"). 97 - Select("repo_did, MAX(start_time)"). 98 - Group("repo_did")). 99 - Where("start_time > ?", thirtySecondsAgo.UTC()). 100 Group("repo_did") 101 102 - rankedLivestreamsSubQuery := m.DB.Table("livestreams"). 103 - Select("livestreams.*, ROW_NUMBER() OVER(PARTITION BY livestreams.repo_did ORDER BY livestreams.created_at DESC) as rn"). 104 - Joins("JOIN repos ON livestreams.repo_did = repos.did") 105 - 106 - mainQuery := m.DB.Table("(?) as ranked_livestreams", rankedLivestreamsSubQuery). 107 - Joins("JOIN (?) as latest_segments ON ranked_livestreams.repo_did = latest_segments.repo_did", latestRecentSegmentsSubQuery). 108 - Select("ranked_livestreams.*, latest_segments.latest_segment_start_time"). 109 - Where("ranked_livestreams.rn = 1") 110 111 if before != nil { 112 mainQuery = mainQuery.Where("livestreams.created_at < ?", *before) 113 } 114 115 - mainQuery = mainQuery.Order("ranked_livestreams.created_at DESC"). 116 Limit(limit). 117 Preload("Repo") 118
··· 83 return &livestream, nil 84 } 85 86 + // Get the latest livestreams for a given list of repo DIDs 87 + func (m *DBModel) GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) { 88 var recentLivestreams []Livestream 89 + now := time.Now().UTC() 90 + 91 + if len(dids) == 0 { 92 + return []Livestream{}, nil 93 + } 94 95 + // Subquery to get the most recent livestream for each repo_did 96 + subQuery := m.DB. 97 + Table("livestreams"). 98 + Select("MAX(created_at) as max_created_at, repo_did"). 99 + Where("repo_did IN ?", dids). 100 Group("repo_did") 101 102 + mainQuery := m.DB. 103 + Table("livestreams"). 104 + Select("livestreams.*"). 105 + Joins("JOIN (?) as sq ON livestreams.repo_did = sq.repo_did AND livestreams.created_at = sq.max_created_at", subQuery). 106 + Where("livestreams.repo_did IN ?", dids). 107 + // exclude livestreams with !hide label on the record 108 + Where("NOT EXISTS (?)", 109 + m.DB.Table("labels"). 110 + Select("1"). 111 + Where("labels.uri = livestreams.uri"). 112 + Where("labels.val = ?", "!hide"). 113 + Where("labels.neg = ?", false). 114 + Where("(labels.exp IS NULL OR labels.exp > ?)", now), 115 + ). 116 + // exclude livestreams with !hide label on the user 117 + Where("NOT EXISTS (?)", 118 + m.DB.Table("labels"). 119 + Select("1"). 120 + Where("labels.uri = livestreams.repo_did"). 121 + Where("labels.val = ?", "!hide"). 122 + Where("labels.neg = ?", false). 123 + Where("(labels.exp IS NULL OR labels.exp > ?)", now), 124 + ) 125 126 if before != nil { 127 mainQuery = mainQuery.Where("livestreams.created_at < ?", *before) 128 } 129 130 + mainQuery = mainQuery. 131 + Order("livestreams.created_at DESC"). 132 Limit(limit). 133 Preload("Repo") 134
+10 -18
pkg/model/model.go
··· 15 "gorm.io/plugin/prometheus" 16 "stream.place/streamplace/pkg/config" 17 "stream.place/streamplace/pkg/log" 18 "stream.place/streamplace/pkg/streamplace" 19 ) 20 21 type DBModel struct { 22 - DB *gorm.DB 23 } 24 25 type Model interface { ··· 27 ListPlayerEvents(playerID string) ([]PlayerEvent, error) 28 PlayerReport(playerID string) (map[string]any, error) 29 ClearPlayerEvents() error 30 - 31 - CreateSegment(segment *Segment) error 32 - MostRecentSegments() ([]Segment, error) 33 - LatestSegmentForUser(user string) (*Segment, error) 34 - LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 35 - FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 36 - CreateThumbnail(thumb *Thumbnail) error 37 - LatestThumbnailForUser(user string) (*Thumbnail, error) 38 - GetSegment(id string) (*Segment, error) 39 - GetExpiredSegments(ctx context.Context) ([]Segment, error) 40 - DeleteSegment(ctx context.Context, id string) error 41 - StartSegmentCleaner(ctx context.Context) error 42 - SegmentCleaner(ctx context.Context) error 43 44 GetIdentity(id string) (*Identity, error) 45 UpdateIdentity(ident *Identity) error ··· 72 CreateLivestream(ctx context.Context, ls *Livestream) error 73 GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) 74 GetLivestreamByPostURI(postURI string) (*Livestream, error) 75 - GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) 76 77 CreateTeleport(ctx context.Context, tp *Teleport) error 78 GetLatestTeleportForRepo(repoDID string) (*Teleport, error) ··· 171 return nil, fmt.Errorf("error using prometheus plugin: %w", err) 172 } 173 174 sqlDB, err := db.DB() 175 if err != nil { 176 return nil, fmt.Errorf("error getting database: %w", err) ··· 178 sqlDB.SetMaxOpenConns(1) 179 for _, model := range []any{ 180 PlayerEvent{}, 181 - Segment{}, 182 - Thumbnail{}, 183 Identity{}, 184 Repo{}, 185 SigningKey{}, ··· 204 return nil, err 205 } 206 } 207 - return &DBModel{DB: db}, nil 208 }
··· 15 "gorm.io/plugin/prometheus" 16 "stream.place/streamplace/pkg/config" 17 "stream.place/streamplace/pkg/log" 18 + "stream.place/streamplace/pkg/stars" 19 "stream.place/streamplace/pkg/streamplace" 20 ) 21 22 type DBModel struct { 23 + DB *gorm.DB 24 + starrer *stars.Starrer 25 } 26 27 type Model interface { ··· 29 ListPlayerEvents(playerID string) ([]PlayerEvent, error) 30 PlayerReport(playerID string) (map[string]any, error) 31 ClearPlayerEvents() error 32 33 GetIdentity(id string) (*Identity, error) 34 UpdateIdentity(ident *Identity) error ··· 61 CreateLivestream(ctx context.Context, ls *Livestream) error 62 GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) 63 GetLivestreamByPostURI(postURI string) (*Livestream, error) 64 + GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) 65 66 CreateTeleport(ctx context.Context, tp *Teleport) error 67 GetLatestTeleportForRepo(repoDID string) (*Teleport, error) ··· 160 return nil, fmt.Errorf("error using prometheus plugin: %w", err) 161 } 162 163 + starrer, err := stars.NewDefaultStarrer() 164 + if err != nil { 165 + return nil, fmt.Errorf("error creating default starrer: %w", err) 166 + } 167 + 168 sqlDB, err := db.DB() 169 if err != nil { 170 return nil, fmt.Errorf("error getting database: %w", err) ··· 172 sqlDB.SetMaxOpenConns(1) 173 for _, model := range []any{ 174 PlayerEvent{}, 175 Identity{}, 176 Repo{}, 177 SigningKey{}, ··· 196 return nil, err 197 } 198 } 199 + return &DBModel{DB: db, starrer: starrer}, nil 200 }
-411
pkg/model/segment.go
··· 1 package model 2 - 3 - import ( 4 - "context" 5 - "database/sql/driver" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "time" 10 - 11 - "gorm.io/gorm" 12 - "stream.place/streamplace/pkg/aqtime" 13 - "stream.place/streamplace/pkg/log" 14 - "stream.place/streamplace/pkg/streamplace" 15 - ) 16 - 17 - type SegmentMediadataVideo struct { 18 - Width int `json:"width"` 19 - Height int `json:"height"` 20 - FPSNum int `json:"fpsNum"` 21 - FPSDen int `json:"fpsDen"` 22 - BFrames bool `json:"bframes"` 23 - } 24 - 25 - type SegmentMediadataAudio struct { 26 - Rate int `json:"rate"` 27 - Channels int `json:"channels"` 28 - } 29 - 30 - type SegmentMediaData struct { 31 - Video []*SegmentMediadataVideo `json:"video"` 32 - Audio []*SegmentMediadataAudio `json:"audio"` 33 - Duration int64 `json:"duration"` 34 - Size int `json:"size"` 35 - } 36 - 37 - // Scan scan value into Jsonb, implements sql.Scanner interface 38 - func (j *SegmentMediaData) Scan(value any) error { 39 - bytes, ok := value.([]byte) 40 - if !ok { 41 - return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) 42 - } 43 - 44 - result := SegmentMediaData{} 45 - err := json.Unmarshal(bytes, &result) 46 - *j = SegmentMediaData(result) 47 - return err 48 - } 49 - 50 - // Value return json value, implement driver.Valuer interface 51 - func (j SegmentMediaData) Value() (driver.Value, error) { 52 - return json.Marshal(j) 53 - } 54 - 55 - // ContentRights represents content rights and attribution information 56 - type ContentRights struct { 57 - CopyrightNotice *string `json:"copyrightNotice,omitempty"` 58 - CopyrightYear *int64 `json:"copyrightYear,omitempty"` 59 - Creator *string `json:"creator,omitempty"` 60 - CreditLine *string `json:"creditLine,omitempty"` 61 - License *string `json:"license,omitempty"` 62 - } 63 - 64 - // Scan scan value into ContentRights, implements sql.Scanner interface 65 - func (c *ContentRights) Scan(value any) error { 66 - if value == nil { 67 - *c = ContentRights{} 68 - return nil 69 - } 70 - bytes, ok := value.([]byte) 71 - if !ok { 72 - return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value)) 73 - } 74 - 75 - result := ContentRights{} 76 - err := json.Unmarshal(bytes, &result) 77 - *c = ContentRights(result) 78 - return err 79 - } 80 - 81 - // Value return json value, implement driver.Valuer interface 82 - func (c ContentRights) Value() (driver.Value, error) { 83 - return json.Marshal(c) 84 - } 85 - 86 - // DistributionPolicy represents distribution policy information 87 - type DistributionPolicy struct { 88 - DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 - } 90 - 91 - // Scan scan value into DistributionPolicy, implements sql.Scanner interface 92 - func (d *DistributionPolicy) Scan(value any) error { 93 - if value == nil { 94 - *d = DistributionPolicy{} 95 - return nil 96 - } 97 - bytes, ok := value.([]byte) 98 - if !ok { 99 - return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value)) 100 - } 101 - 102 - result := DistributionPolicy{} 103 - err := json.Unmarshal(bytes, &result) 104 - *d = DistributionPolicy(result) 105 - return err 106 - } 107 - 108 - // Value return json value, implement driver.Valuer interface 109 - func (d DistributionPolicy) Value() (driver.Value, error) { 110 - return json.Marshal(d) 111 - } 112 - 113 - // ContentWarningsSlice is a custom type for storing content warnings as JSON in the database 114 - type ContentWarningsSlice []string 115 - 116 - // Scan scan value into ContentWarningsSlice, implements sql.Scanner interface 117 - func (c *ContentWarningsSlice) Scan(value any) error { 118 - if value == nil { 119 - *c = ContentWarningsSlice{} 120 - return nil 121 - } 122 - bytes, ok := value.([]byte) 123 - if !ok { 124 - return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value)) 125 - } 126 - 127 - result := ContentWarningsSlice{} 128 - err := json.Unmarshal(bytes, &result) 129 - *c = ContentWarningsSlice(result) 130 - return err 131 - } 132 - 133 - // Value return json value, implement driver.Valuer interface 134 - func (c ContentWarningsSlice) Value() (driver.Value, error) { 135 - return json.Marshal(c) 136 - } 137 - 138 - type Segment struct { 139 - ID string `json:"id" gorm:"primaryKey"` 140 - SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` 141 - SigningKey *SigningKey `json:"signingKey,omitempty" gorm:"foreignKey:DID;references:SigningKeyDID"` 142 - StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` 143 - RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` 144 - Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 145 - Title string `json:"title"` 146 - Size int `json:"size" gorm:"column:size"` 147 - MediaData *SegmentMediaData `json:"mediaData,omitempty"` 148 - ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"` 149 - ContentRights *ContentRights `json:"contentRights,omitempty"` 150 - DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 151 - DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 152 - } 153 - 154 - func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { 155 - aqt := aqtime.FromTime(s.StartTime) 156 - if s.MediaData == nil { 157 - return nil, fmt.Errorf("media data is nil") 158 - } 159 - if len(s.MediaData.Video) == 0 || s.MediaData.Video[0] == nil { 160 - return nil, fmt.Errorf("video data is nil") 161 - } 162 - if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 163 - return nil, fmt.Errorf("audio data is nil") 164 - } 165 - duration := s.MediaData.Duration 166 - sizei64 := int64(s.Size) 167 - 168 - // Convert model metadata to streamplace metadata 169 - var contentRights *streamplace.MetadataContentRights 170 - if s.ContentRights != nil { 171 - contentRights = &streamplace.MetadataContentRights{ 172 - CopyrightNotice: s.ContentRights.CopyrightNotice, 173 - CopyrightYear: s.ContentRights.CopyrightYear, 174 - Creator: s.ContentRights.Creator, 175 - CreditLine: s.ContentRights.CreditLine, 176 - License: s.ContentRights.License, 177 - } 178 - } 179 - 180 - var contentWarnings *streamplace.MetadataContentWarnings 181 - if len(s.ContentWarnings) > 0 { 182 - contentWarnings = &streamplace.MetadataContentWarnings{ 183 - Warnings: []string(s.ContentWarnings), 184 - } 185 - } 186 - 187 - var distributionPolicy *streamplace.MetadataDistributionPolicy 188 - if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 189 - distributionPolicy = &streamplace.MetadataDistributionPolicy{ 190 - DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 191 - } 192 - } 193 - 194 - return &streamplace.Segment{ 195 - LexiconTypeID: "place.stream.segment", 196 - Creator: s.RepoDID, 197 - Id: s.ID, 198 - SigningKey: s.SigningKeyDID, 199 - StartTime: string(aqt), 200 - Duration: &duration, 201 - Size: &sizei64, 202 - ContentRights: contentRights, 203 - ContentWarnings: contentWarnings, 204 - DistributionPolicy: distributionPolicy, 205 - Video: []*streamplace.Segment_Video{ 206 - { 207 - Codec: "h264", 208 - Width: int64(s.MediaData.Video[0].Width), 209 - Height: int64(s.MediaData.Video[0].Height), 210 - Framerate: &streamplace.Segment_Framerate{ 211 - Num: int64(s.MediaData.Video[0].FPSNum), 212 - Den: int64(s.MediaData.Video[0].FPSDen), 213 - }, 214 - Bframes: &s.MediaData.Video[0].BFrames, 215 - }, 216 - }, 217 - Audio: []*streamplace.Segment_Audio{ 218 - { 219 - Codec: "opus", 220 - Rate: int64(s.MediaData.Audio[0].Rate), 221 - Channels: int64(s.MediaData.Audio[0].Channels), 222 - }, 223 - }, 224 - }, nil 225 - } 226 - 227 - func (m *DBModel) CreateSegment(seg *Segment) error { 228 - err := m.DB.Model(Segment{}).Create(seg).Error 229 - if err != nil { 230 - return err 231 - } 232 - return nil 233 - } 234 - 235 - // should return the most recent segment for each user, ordered by most recent first 236 - // only includes segments from the last 30 seconds 237 - func (m *DBModel) MostRecentSegments() ([]Segment, error) { 238 - var segments []Segment 239 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 240 - 241 - err := m.DB.Table("segments"). 242 - Select("segments.*"). 243 - Where("start_time > ?", thirtySecondsAgo.UTC()). 244 - Order("start_time DESC"). 245 - Find(&segments).Error 246 - if err != nil { 247 - return nil, err 248 - } 249 - if segments == nil { 250 - return []Segment{}, nil 251 - } 252 - 253 - segmentMap := make(map[string]Segment) 254 - for _, seg := range segments { 255 - prev, ok := segmentMap[seg.RepoDID] 256 - if !ok { 257 - segmentMap[seg.RepoDID] = seg 258 - } else { 259 - if seg.StartTime.After(prev.StartTime) { 260 - segmentMap[seg.RepoDID] = seg 261 - } 262 - } 263 - } 264 - 265 - filteredSegments := []Segment{} 266 - for _, seg := range segmentMap { 267 - filteredSegments = append(filteredSegments, seg) 268 - } 269 - 270 - return filteredSegments, nil 271 - } 272 - 273 - func (m *DBModel) LatestSegmentForUser(user string) (*Segment, error) { 274 - var seg Segment 275 - err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 276 - if err != nil { 277 - return nil, err 278 - } 279 - return &seg, nil 280 - } 281 - 282 - func (m *DBModel) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 283 - if len(repoDIDs) == 0 { 284 - return []string{}, nil 285 - } 286 - 287 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 288 - 289 - var liveDIDs []string 290 - 291 - err := m.DB.Table("segments"). 292 - Select("DISTINCT repo_did"). 293 - Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 294 - Pluck("repo_did", &liveDIDs).Error 295 - 296 - if err != nil { 297 - return nil, err 298 - } 299 - 300 - return liveDIDs, nil 301 - } 302 - 303 - func (m *DBModel) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 304 - var segs []Segment 305 - if before == nil { 306 - later := time.Now().Add(1000 * time.Hour) 307 - before = &later 308 - } 309 - if after == nil { 310 - earlier := time.Time{} 311 - after = &earlier 312 - } 313 - 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 314 - if err != nil { 315 - return nil, err 316 - } 317 - return segs, nil 318 - } 319 - 320 - func (m *DBModel) GetSegment(id string) (*Segment, error) { 321 - var seg Segment 322 - 323 - err := m.DB.Model(&Segment{}). 324 - Preload("Repo"). 325 - Where("id = ?", id). 326 - First(&seg).Error 327 - 328 - if errors.Is(err, gorm.ErrRecordNotFound) { 329 - return nil, nil 330 - } 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return &seg, nil 336 - } 337 - 338 - func (m *DBModel) GetExpiredSegments(ctx context.Context) ([]Segment, error) { 339 - 340 - var expiredSegments []Segment 341 - now := time.Now() 342 - err := m.DB. 343 - Where("delete_after IS NOT NULL AND delete_after < ?", now.UTC()). 344 - Find(&expiredSegments).Error 345 - if err != nil { 346 - return nil, err 347 - } 348 - 349 - return expiredSegments, nil 350 - } 351 - 352 - func (m *DBModel) DeleteSegment(ctx context.Context, id string) error { 353 - return m.DB.Delete(&Segment{}, "id = ?", id).Error 354 - } 355 - 356 - func (m *DBModel) StartSegmentCleaner(ctx context.Context) error { 357 - err := m.SegmentCleaner(ctx) 358 - if err != nil { 359 - return err 360 - } 361 - ticker := time.NewTicker(1 * time.Minute) 362 - defer ticker.Stop() 363 - 364 - for { 365 - select { 366 - case <-ctx.Done(): 367 - return nil 368 - case <-ticker.C: 369 - err := m.SegmentCleaner(ctx) 370 - if err != nil { 371 - log.Error(ctx, "Failed to clean segments", "error", err) 372 - } 373 - } 374 - } 375 - } 376 - 377 - func (m *DBModel) SegmentCleaner(ctx context.Context) error { 378 - // Calculate the cutoff time (10 minutes ago) 379 - cutoffTime := aqtime.FromTime(time.Now().Add(-10 * time.Minute)).Time() 380 - 381 - // Find all unique repo_did values 382 - var repoDIDs []string 383 - if err := m.DB.Model(&Segment{}).Distinct("repo_did").Pluck("repo_did", &repoDIDs).Error; err != nil { 384 - log.Error(ctx, "Failed to get unique repo_dids for segment cleaning", "error", err) 385 - return err 386 - } 387 - 388 - // For each user, keep their last 10 segments and delete older ones 389 - for _, repoDID := range repoDIDs { 390 - // Get IDs of the last 10 segments for this user 391 - var keepSegmentIDs []string 392 - if err := m.DB.Model(&Segment{}). 393 - Where("repo_did = ?", repoDID). 394 - Order("start_time DESC"). 395 - Limit(10). 396 - Pluck("id", &keepSegmentIDs).Error; err != nil { 397 - log.Error(ctx, "Failed to get segment IDs to keep", "repo_did", repoDID, "error", err) 398 - return err 399 - } 400 - 401 - // Delete old segments except the ones we want to keep 402 - result := m.DB.Where("repo_did = ? AND start_time < ? AND id NOT IN ?", 403 - repoDID, cutoffTime, keepSegmentIDs).Delete(&Segment{}) 404 - 405 - if result.Error != nil { 406 - log.Error(ctx, "Failed to clean old segments", "repo_did", repoDID, "error", result.Error) 407 - } else if result.RowsAffected > 0 { 408 - log.Log(ctx, "Cleaned old segments", "repo_did", repoDID, "count", result.RowsAffected) 409 - } 410 - } 411 - return nil 412 - }
··· 1 package model
-65
pkg/model/segment_test.go
··· 1 package model 2 - 3 - import ( 4 - "fmt" 5 - "sync" 6 - "testing" 7 - "time" 8 - 9 - "github.com/stretchr/testify/require" 10 - "stream.place/streamplace/pkg/config" 11 - ) 12 - 13 - func TestSegmentPerf(t *testing.T) { 14 - config.DisableSQLLogging() 15 - // dburl := filepath.Join(t.TempDir(), "test.db") 16 - db, err := MakeDB(":memory:") 17 - require.NoError(t, err) 18 - // Create a model instance 19 - model := db.(*DBModel) 20 - t.Cleanup(func() { 21 - // os.Remove(dburl) 22 - }) 23 - 24 - // Create a repo for testing 25 - repo := &Repo{ 26 - DID: "did:plc:test123", 27 - } 28 - err = model.DB.Create(repo).Error 29 - require.NoError(t, err) 30 - 31 - defer config.EnableSQLLogging() 32 - // Create 250000 segments with timestamps 1 hour ago, each one second apart 33 - wg := sync.WaitGroup{} 34 - segCount := 250000 35 - wg.Add(segCount) 36 - baseTime := time.Now() 37 - for i := 0; i < segCount; i++ { 38 - segment := &Segment{ 39 - ID: fmt.Sprintf("segment-%d", i), 40 - RepoDID: repo.DID, 41 - StartTime: baseTime.Add(-time.Duration(i) * time.Second).UTC(), 42 - } 43 - go func() { 44 - defer wg.Done() 45 - err = model.DB.Create(segment).Error 46 - require.NoError(t, err) 47 - }() 48 - } 49 - wg.Wait() 50 - 51 - startTime := time.Now() 52 - wg = sync.WaitGroup{} 53 - runs := 1000 54 - wg.Add(runs) 55 - for i := 0; i < runs; i++ { 56 - go func() { 57 - defer wg.Done() 58 - _, err := model.MostRecentSegments() 59 - require.NoError(t, err) 60 - // require.Len(t, segments, 1) 61 - }() 62 - } 63 - wg.Wait() 64 - fmt.Printf("Time taken: %s\n", time.Since(startTime)) 65 - require.Less(t, time.Since(startTime), 10*time.Second) 66 - }
··· 1 package model
-59
pkg/model/thumbnail.go
··· 1 package model 2 - 3 - import ( 4 - "fmt" 5 - 6 - "github.com/google/uuid" 7 - ) 8 - 9 - type Thumbnail struct { 10 - ID string `json:"id" gorm:"primaryKey"` 11 - Format string `json:"format"` 12 - SegmentID string `json:"segmentId" gorm:"index"` 13 - Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 - } 15 - 16 - func (m *DBModel) CreateThumbnail(thumb *Thumbnail) error { 17 - uu, err := uuid.NewV7() 18 - if err != nil { 19 - return err 20 - } 21 - if thumb.SegmentID == "" { 22 - return fmt.Errorf("segmentID is required") 23 - } 24 - thumb.ID = uu.String() 25 - err = m.DB.Model(Thumbnail{}).Create(thumb).Error 26 - if err != nil { 27 - return err 28 - } 29 - return nil 30 - } 31 - 32 - // return the most recent thumbnail for a user 33 - func (m *DBModel) LatestThumbnailForUser(user string) (*Thumbnail, error) { 34 - var thumbnail Thumbnail 35 - 36 - res := m.DB.Table("thumbnails AS t"). 37 - Select("t.*"). 38 - Joins("JOIN segments AS s ON t.segment_id = s.id"). 39 - Where("s.repo_did = ?", user). 40 - Order("s.start_time DESC"). 41 - Limit(1). 42 - Scan(&thumbnail) 43 - 44 - if res.RowsAffected == 0 { 45 - return nil, nil 46 - } 47 - if res.Error != nil { 48 - return nil, res.Error 49 - } 50 - 51 - var seg Segment 52 - err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 53 - if err != nil { 54 - return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 55 - } 56 - 57 - thumbnail.Segment = seg 58 - 59 - return &thumbnail, nil 60 - }
··· 1 package model
+1 -1
pkg/spxrpc/app_bsky_feed.go
··· 56 outCursor = fmt.Sprintf("%d::%s", ts, last.CID) 57 } 58 } else if name == FeedLiveStreams { 59 - segs, err := s.model.MostRecentSegments() 60 if err != nil { 61 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get recent segments: %v", err)) 62 }
··· 56 outCursor = fmt.Sprintf("%d::%s", ts, last.CID) 57 } 58 } else if name == FeedLiveStreams { 59 + segs, err := s.localDB.MostRecentSegments() 60 if err != nil { 61 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get recent segments: %v", err)) 62 }
+2 -1
pkg/spxrpc/com_atproto_identity.go
··· 5 6 comatprototypes "github.com/bluesky-social/indigo/api/atproto" 7 "github.com/streamplace/oatproxy/pkg/oatproxy" 8 ) 9 10 func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 11 - did, err := oatproxy.ResolveHandle(ctx, handle) 12 if err != nil { 13 return nil, err 14 }
··· 5 6 comatprototypes "github.com/bluesky-social/indigo/api/atproto" 7 "github.com/streamplace/oatproxy/pkg/oatproxy" 8 + "stream.place/streamplace/pkg/aqhttp" 9 ) 10 11 func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 12 + did, err := oatproxy.ResolveHandleWithClient(ctx, handle, &aqhttp.Client) 13 if err != nil { 14 return nil, err 15 }
+4 -4
pkg/spxrpc/com_atproto_moderation.go
··· 13 "github.com/labstack/echo/v4" 14 "github.com/streamplace/oatproxy/pkg/oatproxy" 15 "stream.place/streamplace/pkg/config" 16 "stream.place/streamplace/pkg/log" 17 "stream.place/streamplace/pkg/media" 18 - "stream.place/streamplace/pkg/model" 19 ) 20 21 func (s *Server) handleComAtprotoModerationCreateReport(ctx context.Context, body *comatprototypes.ModerationCreateReport_Input) (*comatprototypes.ModerationCreateReport_Output, error) { ··· 76 return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid subject") 77 } 78 79 - clipID, err := makeClip(ctx, s.cli, s.model, did) 80 if err != nil { 81 // we still want the report to go through! 82 log.Error(ctx, "failed to make clip for report", "error", err) ··· 99 return &output, nil 100 } 101 102 - func makeClip(ctx context.Context, cli *config.CLI, mod model.Model, did string) (string, error) { 103 after := time.Now().Add(-time.Duration(60) * time.Second) 104 105 uu, err := uuid.NewV7() ··· 113 } 114 defer fd.Close() 115 116 - err = media.ClipUser(ctx, mod, cli, did, fd, nil, &after) 117 if err != nil { 118 return "", echo.NewHTTPError(http.StatusInternalServerError, "failed to clip user") 119 }
··· 13 "github.com/labstack/echo/v4" 14 "github.com/streamplace/oatproxy/pkg/oatproxy" 15 "stream.place/streamplace/pkg/config" 16 + "stream.place/streamplace/pkg/localdb" 17 "stream.place/streamplace/pkg/log" 18 "stream.place/streamplace/pkg/media" 19 ) 20 21 func (s *Server) handleComAtprotoModerationCreateReport(ctx context.Context, body *comatprototypes.ModerationCreateReport_Input) (*comatprototypes.ModerationCreateReport_Output, error) { ··· 76 return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid subject") 77 } 78 79 + clipID, err := makeClip(ctx, s.cli, s.localDB, did) 80 if err != nil { 81 // we still want the report to go through! 82 log.Error(ctx, "failed to make clip for report", "error", err) ··· 99 return &output, nil 100 } 101 102 + func makeClip(ctx context.Context, cli *config.CLI, localDB localdb.LocalDB, did string) (string, error) { 103 after := time.Now().Add(-time.Duration(60) * time.Second) 104 105 uu, err := uuid.NewV7() ··· 113 } 114 defer fd.Close() 115 116 + err = media.ClipUser(ctx, localDB, cli, did, fd, nil, &after) 117 if err != nil { 118 return "", echo.NewHTTPError(http.StatusInternalServerError, "failed to clip user") 119 }
+3 -2
pkg/spxrpc/com_atproto_repo.go
··· 15 "github.com/labstack/echo/v4" 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 17 "go.opentelemetry.io/otel" 18 "stream.place/streamplace/pkg/atproto" 19 "stream.place/streamplace/pkg/log" 20 ) ··· 23 did := repo 24 var err error 25 if !strings.HasPrefix(repo, "did:") { 26 - did, err = oatproxy.ResolveHandle(ctx, repo) 27 if err != nil { 28 return "", "", "", fmt.Errorf("failed to resolve handle %q: %w", repo, err) 29 } 30 } 31 32 - service, handle, err := oatproxy.ResolveService(ctx, did) 33 if err != nil { 34 return "", "", "", fmt.Errorf("failed to resolve service for did %q: %w", did, err) 35 }
··· 15 "github.com/labstack/echo/v4" 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 17 "go.opentelemetry.io/otel" 18 + "stream.place/streamplace/pkg/aqhttp" 19 "stream.place/streamplace/pkg/atproto" 20 "stream.place/streamplace/pkg/log" 21 ) ··· 24 did := repo 25 var err error 26 if !strings.HasPrefix(repo, "did:") { 27 + did, err = oatproxy.ResolveHandleWithClient(ctx, repo, &aqhttp.Client) 28 if err != nil { 29 return "", "", "", fmt.Errorf("failed to resolve handle %q: %w", repo, err) 30 } 31 } 32 33 + service, handle, err := oatproxy.ResolveServiceWithClient(ctx, did, &aqhttp.Client) 34 if err != nil { 35 return "", "", "", fmt.Errorf("failed to resolve service for did %q: %w", did, err) 36 }
+11
pkg/spxrpc/com_atproto_sync.go
··· 46 }, 47 } 48 49 func (s *Server) handleComAtprotoSyncSubscribeRepos(c echo.Context) error { 50 ctx := log.WithLogValues(c.Request().Context(), "client_ip", c.RealIP(), "user_agent", c.Request().UserAgent()) 51 cursor := c.QueryParam("cursor")
··· 46 }, 47 } 48 49 + func (s *Server) handleComAtprotoSyncGetRepo(ctx context.Context, did string, since string) (io.Reader, error) { 50 + if did != atproto.LexiconRepo.RepoDid() { 51 + return nil, echo.NewHTTPError(http.StatusNotFound, "RepoNotFound") 52 + } 53 + bs, err := atproto.LexiconRepoGetRepo(ctx, since) 54 + if err != nil { 55 + return nil, err 56 + } 57 + return bytes.NewReader(bs), nil 58 + } 59 + 60 func (s *Server) handleComAtprotoSyncSubscribeRepos(c echo.Context) error { 61 ctx := log.WithLogValues(c.Request().Context(), "client_ip", c.RealIP(), "user_agent", c.Request().UserAgent()) 62 cursor := c.QueryParam("cursor")
+4 -4
pkg/spxrpc/place_stream_branding.go
··· 38 return s.cli.BroadcasterHost 39 } 40 41 - func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 // cache miss - fetch from db 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 if err == gorm.ErrRecordNotFound { ··· 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 65 if err != nil { 66 return nil, err 67 } ··· 94 // build output 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 for key := range allKeys { 97 - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 98 if err != nil { 99 continue // skip if error 100 } ··· 238 239 broadcasterID := s.cli.BroadcasterHost 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 243 if err != nil || data == nil { 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
··· 38 return s.cli.BroadcasterHost 39 } 40 41 + func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 // cache miss - fetch from db 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 if err == gorm.ErrRecordNotFound { ··· 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) 65 if err != nil { 66 return nil, err 67 } ··· 94 // build output 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 for key := range allKeys { 97 + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) 98 if err != nil { 99 continue // skip if error 100 } ··· 238 239 broadcasterID := s.cli.BroadcasterHost 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 243 if err != nil || data == nil { 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
+13 -5
pkg/spxrpc/place_stream_live.go
··· 82 beforeTime = &parsedTime 83 } 84 85 - segments, err := s.model.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 86 if err != nil { 87 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch segments") 88 } ··· 127 } 128 beforeTime = &parsedTime 129 } 130 - ls, err := s.model.GetLatestLivestreams(limit, beforeTime) 131 if err != nil { 132 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch livestreams") 133 } ··· 223 } 224 225 // Filter for only live streamers 226 - liveStreamers, err := s.model.FilterLiveRepoDIDs(streamers) 227 if err != nil { 228 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live streamers") 229 } ··· 256 followDIDs[i] = follow.SubjectDID 257 } 258 259 - liveFollows, err := s.model.FilterLiveRepoDIDs(followDIDs) 260 if err != nil { 261 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live follows") 262 } ··· 281 // Final fallback: use host's default recommendations 282 defaultStreamers := s.cli.DefaultRecommendedStreamers 283 if len(defaultStreamers) > 0 { 284 - liveDefaults, err := s.model.FilterLiveRepoDIDs(defaultStreamers) 285 if err != nil { 286 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") 287 }
··· 82 beforeTime = &parsedTime 83 } 84 85 + segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 86 if err != nil { 87 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch segments") 88 } ··· 127 } 128 beforeTime = &parsedTime 129 } 130 + segs, err := s.localDB.MostRecentSegments() 131 + if err != nil { 132 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch recent segments") 133 + } 134 + dids := make([]string, len(segs)) 135 + for i, seg := range segs { 136 + dids[i] = seg.RepoDID 137 + } 138 + ls, err := s.model.GetLatestLivestreams(limit, beforeTime, dids) 139 if err != nil { 140 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch livestreams") 141 } ··· 231 } 232 233 // Filter for only live streamers 234 + liveStreamers, err := s.localDB.FilterLiveRepoDIDs(streamers) 235 if err != nil { 236 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live streamers") 237 } ··· 264 followDIDs[i] = follow.SubjectDID 265 } 266 267 + liveFollows, err := s.localDB.FilterLiveRepoDIDs(followDIDs) 268 if err != nil { 269 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live follows") 270 } ··· 289 // Final fallback: use host's default recommendations 290 defaultStreamers := s.cli.DefaultRecommendedStreamers 291 if len(defaultStreamers) > 0 { 292 + liveDefaults, err := s.localDB.FilterLiveRepoDIDs(defaultStreamers) 293 if err != nil { 294 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") 295 }
+4 -1
pkg/spxrpc/spxrpc.go
··· 18 "stream.place/streamplace/pkg/atproto" 19 "stream.place/streamplace/pkg/bus" 20 "stream.place/streamplace/pkg/config" 21 "stream.place/streamplace/pkg/log" 22 "stream.place/streamplace/pkg/model" 23 "stream.place/streamplace/pkg/statedb" ··· 33 statefulDB *statedb.StatefulDB 34 bus *bus.Bus 35 op *oatproxy.OATProxy 36 } 37 38 - 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) (*Server, error) { 39 e := echo.New() 40 s := &Server{ 41 e: e, ··· 47 statefulDB: statefulDB, 48 bus: bus, 49 op: op, 50 } 51 e.Use(s.ErrorHandlingMiddleware()) 52 e.Use(s.ContextPreservingMiddleware())
··· 18 "stream.place/streamplace/pkg/atproto" 19 "stream.place/streamplace/pkg/bus" 20 "stream.place/streamplace/pkg/config" 21 + "stream.place/streamplace/pkg/localdb" 22 "stream.place/streamplace/pkg/log" 23 "stream.place/streamplace/pkg/model" 24 "stream.place/streamplace/pkg/statedb" ··· 34 statefulDB *statedb.StatefulDB 35 bus *bus.Bus 36 op *oatproxy.OATProxy 37 + localDB localdb.LocalDB 38 } 39 40 + 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) { 41 e := echo.New() 42 s := &Server{ 43 e: e, ··· 49 statefulDB: statefulDB, 50 bus: bus, 51 op: op, 52 + localDB: ldb, 53 } 54 e.Use(s.ErrorHandlingMiddleware()) 55 e.Use(s.ContextPreservingMiddleware())
+16
pkg/spxrpc/stubs.go
··· 71 e.POST("/xrpc/com.atproto.repo.uploadBlob", s.HandleComAtprotoRepoUploadBlob) 72 e.GET("/xrpc/com.atproto.server.describeServer", s.HandleComAtprotoServerDescribeServer) 73 e.GET("/xrpc/com.atproto.sync.getRecord", s.HandleComAtprotoSyncGetRecord) 74 e.GET("/xrpc/com.atproto.sync.listRepos", s.HandleComAtprotoSyncListRepos) 75 return nil 76 } ··· 230 var handleErr error 231 // func (s *Server) handleComAtprotoSyncGetRecord(ctx context.Context,collection string,did string,rkey string) (io.Reader, error) 232 out, handleErr = s.handleComAtprotoSyncGetRecord(ctx, collection, did, rkey) 233 if handleErr != nil { 234 return handleErr 235 }
··· 71 e.POST("/xrpc/com.atproto.repo.uploadBlob", s.HandleComAtprotoRepoUploadBlob) 72 e.GET("/xrpc/com.atproto.server.describeServer", s.HandleComAtprotoServerDescribeServer) 73 e.GET("/xrpc/com.atproto.sync.getRecord", s.HandleComAtprotoSyncGetRecord) 74 + e.GET("/xrpc/com.atproto.sync.getRepo", s.HandleComAtprotoSyncGetRepo) 75 e.GET("/xrpc/com.atproto.sync.listRepos", s.HandleComAtprotoSyncListRepos) 76 return nil 77 } ··· 231 var handleErr error 232 // func (s *Server) handleComAtprotoSyncGetRecord(ctx context.Context,collection string,did string,rkey string) (io.Reader, error) 233 out, handleErr = s.handleComAtprotoSyncGetRecord(ctx, collection, did, rkey) 234 + if handleErr != nil { 235 + return handleErr 236 + } 237 + return c.Stream(200, "application/vnd.ipld.car", out) 238 + } 239 + 240 + func (s *Server) HandleComAtprotoSyncGetRepo(c echo.Context) error { 241 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetRepo") 242 + defer span.End() 243 + did := c.QueryParam("did") 244 + since := c.QueryParam("since") 245 + var out io.Reader 246 + var handleErr error 247 + // func (s *Server) handleComAtprotoSyncGetRepo(ctx context.Context,did string,since string) (io.Reader, error) 248 + out, handleErr = s.handleComAtprotoSyncGetRepo(ctx, did, since) 249 if handleErr != nil { 250 return handleErr 251 }
+156
pkg/stars/stars.go
···
··· 1 + package stars 2 + 3 + // censors a message (with stars) based on regex patterns 4 + import ( 5 + "encoding/json" 6 + "os" 7 + "regexp" 8 + "strings" 9 + 10 + appbsky "github.com/bluesky-social/indigo/api/bsky" 11 + "stream.place/streamplace/pkg/streamplace" 12 + ) 13 + 14 + // PatternDef defines a pattern with its associated categories 15 + type PatternDef struct { 16 + Pattern string 17 + Categories []string 18 + } 19 + 20 + // Default patterns for common profanity and slurs (case-insensitive) 21 + var DefaultPatterns = []PatternDef{ 22 + {`(?i)\b[cCϲсᴄⅽcçćčĉċ¢©🅒🅲𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ⓒⒸᶜ\(\[\{<ⲥꮯ€🇨][uUυսuùúûüũūŭůűųưᴜᵘᵤ🅤🆄𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎𝘂𝘶𝙪𝚞ⓤⓊʋꞟꭎꭒ𑣘ט𑜆🇺𝖀vμ][nNոռnñńņňʼnṅṇṉṋɴⁿ🅝🅽𝐧𝑛𝒏𝓃𝓷𝔫𝕟𝖓𝗇𝗻𝘯𝙣𝚗ⓝⓃ🇳ηŋℕ𝕹][tTтtţťŧṫṭṯṱᴛᵗ7\+🅣🆃𝐭𝑡𝒕𝓉𝓽𝔱𝕥𝖙𝗍𝘁𝘵𝙩𝚝ⓣⓉ†✝]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 23 + {`(?i)\b(n|\|\\||🇳|ո|ռ|🅝|𝕹)+(i|1|!|\||l|🇮|ℹ️|ı|ɩ|ɪ|ӏ|Ꭵ|ꙇ|ꭵ|ǀ|Ι|І|Ӏ|׀|ו|ן|١|۱|ا|Ⲓ|ⵏ|ꓲ|𐊊|𐌉|𐌠|𖼨|ﺍ|ﺎ|│|🅘|𝕴)+(g|9|🇬|ƍ|ɡ|ᶃ|🅖|𝕲)`, []string{"place.stream.richtext.defs#discriminatory"}}, 24 + {`(?i)\b(f|ƒ|£|🇫|ẝ|ꞙ|ꬵ|🅕|𝖿|𝕱)+(a|4|@|∆|/-\\|/_\\|Д|🇦|🅰️|ɑ|а|🅐|𝖺|𝕬)+(g|9|🇬|ƍ|ɡ|ᶃ|🅖|𝕲)+`, []string{"place.stream.richtext.defs#discriminatory"}}, 25 + {`(?i)\b[rRᎡꓣ𝐑𝐫𝑹𝒓ℛℜℝ𝓇𝓡𝓻𝔯𝕣𝕽𝖗𝖱𝗋𝗥𝗿𝘙𝘳𝙍𝙧𝚁𝚛ⓇⓡʀᴿʳŕŗřȑȓɍɹɻɼɽɾṙṛṝṟгГ®🅡🆁🄡][eEЕеᎬꓰ𝐄𝐞𝑬𝒆ℰℯ𝓔𝓮𝔈𝔢𝔼𝕖𝕰𝖊𝖤𝖾𝗘𝗲𝘌𝘦𝙀𝙚𝙴𝚎ⒺⓔᴇᴱᵉₑɛεΕёЁèéêëēĕėęěȅȇȩɇѐєҽ3€🅔🅴🄴℮ǝƎ∃][tTТтᎢꓔ𝐓𝐭𝑻𝒕𝒯𝓉𝓣𝓽𝔗𝔱𝕋𝕥𝕿𝖙𝖳𝗍𝗧𝘁𝘛𝘵𝙏𝙩𝚃𝚝Ⓣⓣᴛᵀᵗţťŧțȶṫṭṯṱ7\+†✝🅣🆃🄣][aAАаᎪꓮꭺ𐊠𝐀𝐚𝑨𝒂𝒜𝒶𝓐𝓪𝔄𝔞𝔸𝕒𝕬𝖆𝖠𝖺𝗔𝗮𝘈𝘢𝘼𝙖𝙰𝚊𝚨ⒶⓐᴀᴬᵃᵅₐɐɑαΑäàáâãåāăąǎǟǡǻȁȃȧӑӓᾀᾁᾂᾃᾄᾅᾆᾇᾰᾱᾲᾳᾴᾶᾷὰά@4🅐🅰🄰∀Λλ][rRᎡꓣ𝐑𝐫𝑹𝒓ℛℜℝ𝓇𝓡𝓻𝔯𝕣𝕽𝖗𝖱𝗋𝗥𝗿𝘙𝘳𝙍𝙧𝚁𝚛ⓇⓡʀᴿʳŕŗřȑȓɍɹɻɼɽɾṙṛṝṟгГ®🅡🆁🄡][dDᎠꓓ𝐃𝐝𝑫𝒅𝒟𝒹𝓓𝓭𝔇𝔡𝔻d𝕯𝖉𝖣𝖽𝗗𝗱𝘋𝘥𝘿𝙙𝙳𝚍ⒹⓓᴅᴰᵈԀԁɖɗďđðḋḍḏḑḓ🅓🅳🄳ⅅⅆ]+`, []string{"place.stream.richtext.defs#discriminatory"}}, 26 + {`(?i)\b[bBВвᏴꓐ𐊂𐊡𐌁𝐁𝐛𝑩𝒃𝒷𝓑𝓫𝔅𝔟𝔹𝕓𝕭𝖇𝖡𝖻𝗕𝗯𝘉𝘣𝘽𝙗𝙱𝚋𝚩ⒷⓑᴃᴮᵇƀɓЬьβΒḃḅḇ68ßþ🅑🅱🄱ℬ][iIІіӀꓲ𝐈𝐢𝑰𝒊ℐℑ𝒾𝓘𝓲𝔦𝕀𝕚𝕴𝖎𝖨𝗂𝗜𝗶𝘐𝘪𝙄𝙞𝙸𝚒𝚤Ⓘⓘɪᴵⁱᵢìíîïĩīĭįıǐȉȋḭḯỉịӏ1l\|!¡🅘🅸🄸ⅰⅠ][tTТтᎢꓔ𝐓𝐭𝑻𝒕𝒯𝓉𝓣𝓽𝔗𝔱𝕋𝕥𝕿𝖙𝖳𝗍𝗧𝘁𝘛𝘵𝙏𝙩𝚃𝚝Ⓣⓣᴛᵀᵗţťŧțȶṫṭṯṱ7\+†✝🅣🆃🄣][cCСсᏟꓚꮯ𐊢𐌂𐐕𐐽𝐂𝐜𝑪𝒄𝒞𝒸𝓒𝓬ℭ𝔠ℂ𝕔𝕮𝖈𝖢𝖼𝗖𝗰𝘊𝘤𝘾𝙘𝙲𝚌ⒸⓒᴄↃↄϲϹçćĉċčƈȼ¢©€🅒🅲🄲⊂⊃ᑕᑢ\(<\[\{][hHНнᎻꓧ𝐇𝐡𝑯𝒉ℋℌℍ𝒽𝓗𝓱𝔥𝕙𝕳𝖍𝖧𝗁𝗛𝗵𝘏𝘩𝙃𝙝𝙷𝚑Ⓗⓗʜᴴʰĥħȟḣḥḧḩḫհңһ#🅗🅷🄷♄]+`, []string{"place.stream.richtext.defs#profanity"}}, 27 + {`(?i)\b[dDԁɗḋḍḏḑḓdᴅᵈ🅓🅳𝐝𝑑𝒅𝒹𝓭𝔡𝕕𝖉𝖽𝗱𝘥𝙙𝚍ⓓⒹđð][iIіıɪiìíîïĩīĭįǐ1l\|!🅘🅸𝐢𝑖𝒊𝒾𝓲𝔦𝕚𝖎𝗂𝗶𝘪𝙞𝚒ⓘⒾᵢⁱ¡ǃ][cCϲсᴄⅽcçćčĉċ¢©🅒🅲𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ⓒⒸᶜ\(\[\{<ⲥꮯ€🇨][kKκkḱḳḵķᴋᵏ🅚🅺𝐤𝑘𝒌𝓀𝓴𝔨𝕜𝖐𝗄𝗸𝘬𝙠𝚔ⓚⓀꮶ]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 28 + {`(?i)\b[pPрρpṕṗᴘᵖ🅟🅿𝐩𝑝𝒑𝓅𝓹𝔭𝕡𝖕𝗉𝗽𝘱𝙥𝚙ⓟⓅ℘][uUυսuùúûüũūŭůűųưᴜᵘᵤ🅤🆄𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎𝘂𝘶𝙪𝚞ⓤⓊʋꞟꭎꭒ𑣘ט𑜆🇺𝖀vμ][sSѕꜱsśŝşšṡṣṥṧṩˢ\$5🅢🆂𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ⓢⓈ§][sSѕꜱsśŝşšṡṣṥṧṩˢ\$5🅢🆂𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ⓢⓈ§][yYуүγyỳýŷÿỹȳɣʏყᶌỿℽꭚ𑣄¥🅨🆈𝐲𝑦𝒚𝓎𝔂𝔶𝕪𝖞𝗒𝘆𝘺𝙮𝚢ⓨⓎʸ🇾𝖄]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 29 + {`(?i)\b(f|ƒ|£|🇫|ẝ|ꞙ|ꬵ|🅕|𝖿|𝕱)+(u|v|🇺|ʋ|υ|ս|ᴜ|ꞟ|ꭎ|ꭒ|𑣘|ט|𑜆|🅤|𝗎|𝖀)+(c|\(|€|🇨|©️|ϲ|с|ᴄ|ⲥ|ꮯ|🅒|𝖢|𝕮)+(k|\|<|🇰|🅚|𝕶)+`, []string{"place.stream.richtext.defs#profanity"}}, 30 + // from https://github.com/bluesky-social/atproto/blob/7b9a98a763636c5f66a06da11fe6013f29dd9157/lexicons/app/bsky/richtext/facet.json 31 + {`/\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][hĤĥȞȟḦḧḢḣḨḩḤḥḪḫH̱ẖĦħⱧⱨꞪɦꞕΗНн][iÍíi̇́Ììi̇̀Ĭĭcccccbvnnuugtbekdkibdcrbceidjbticigulkbikbbl 32 + ÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 33 + {`/\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 34 + {`/\b[fḞḟƑƒꞘꞙᵮᶂ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa@4][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGg]{1,2}([ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeiÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ]{1,2}([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 35 + {`/\b[kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLlyÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]*\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 36 + {`/\b[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeaÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ]?|n[ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]|[a4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa]?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 37 + {`/[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?/`, []string{"place.stream.richtext.defs#discriminatory"}}, 38 + {`/\b[tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa4]+[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn]{1,2}([iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]|[yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 39 + {`(?i)\bANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86\w*`, []string{"place.stream.richtext.defs#profanity"}}, 40 + } 41 + 42 + type patternWithCategories struct { 43 + pattern *regexp.Regexp 44 + categories []string 45 + } 46 + 47 + type Starrer struct { 48 + patterns []patternWithCategories 49 + } 50 + 51 + func NewStarrer(patternDefs []PatternDef) (*Starrer, error) { 52 + patterns := make([]patternWithCategories, 0, len(patternDefs)) 53 + for _, pd := range patternDefs { 54 + re, err := regexp.Compile(pd.Pattern) 55 + if err != nil { 56 + return nil, err 57 + } 58 + patterns = append(patterns, patternWithCategories{ 59 + pattern: re, 60 + categories: pd.Categories, 61 + }) 62 + } 63 + return &Starrer{patterns: patterns}, nil 64 + } 65 + 66 + // NewDefaultStarrer creates a Starrer with the default profanity patterns 67 + func NewDefaultStarrer() (*Starrer, error) { 68 + return NewStarrer(DefaultPatterns) 69 + } 70 + 71 + // LoadPatternsFromJSON loads pattern definitions from a JSON file 72 + func LoadPatternsFromJSON(filepath string) ([]PatternDef, error) { 73 + data, err := os.ReadFile(filepath) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + var patterns []PatternDef 79 + if err := json.Unmarshal(data, &patterns); err != nil { 80 + return nil, err 81 + } 82 + 83 + return patterns, nil 84 + } 85 + 86 + // NewStarrerFromJSON creates a Starrer from a JSON file containing pattern definitions 87 + func NewStarrerFromJSON(filepath string) (*Starrer, error) { 88 + patterns, err := LoadPatternsFromJSON(filepath) 89 + if err != nil { 90 + return nil, err 91 + } 92 + return NewStarrer(patterns) 93 + } 94 + 95 + func (s *Starrer) CensorMessageView(msg *streamplace.ChatDefs_MessageView) (*streamplace.ChatDefs_MessageView, error) { 96 + if msg.Record == nil || msg.Record.ChatDefs_MessageRecordView == nil { 97 + return msg, nil 98 + } 99 + 100 + record := msg.Record.ChatDefs_MessageRecordView 101 + text := record.Text 102 + 103 + // Find all matches across all patterns and create censor facets 104 + var newFacets []*streamplace.RichtextDefs_FacetView 105 + 106 + for _, pwc := range s.patterns { 107 + indices := pwc.pattern.FindAllStringIndex(text, -1) 108 + for _, idx := range indices { 109 + matchedText := text[idx[0]:idx[1]] 110 + byteStart := len([]byte(text[:idx[0]])) 111 + byteEnd := len([]byte(text[:idx[1]])) 112 + 113 + censorFacet := &streamplace.RichtextDefs_FacetView{ 114 + Index: &appbsky.RichtextFacet_ByteSlice{ 115 + ByteStart: int64(byteStart), 116 + ByteEnd: int64(byteEnd), 117 + }, 118 + Features: []*streamplace.RichtextDefs_FacetView_Features_Elem{ 119 + { 120 + RichtextDefs_Censor: &streamplace.RichtextDefs_Censor{ 121 + LexiconTypeID: "place.stream.richtext.defs#censor", 122 + Reason: &matchedText, 123 + Categories: pwc.categories, 124 + }, 125 + }, 126 + }, 127 + } 128 + newFacets = append(newFacets, censorFacet) 129 + } 130 + } 131 + 132 + if len(newFacets) == 0 { 133 + return msg, nil 134 + } 135 + 136 + // Copy the message and add censor facets 137 + censoredMsg := *msg 138 + censoredRecord := *record 139 + censoredRecord.Facets = append(censoredRecord.Facets, newFacets...) 140 + censoredMsg.Record = &streamplace.ChatDefs_MessageView_Record{ 141 + ChatDefs_MessageRecordView: &censoredRecord, 142 + } 143 + 144 + return &censoredMsg, nil 145 + } 146 + 147 + // Censor returns the censored version of the input string 148 + func (s *Starrer) Censor(input string) string { 149 + censored := input 150 + for _, pwc := range s.patterns { 151 + censored = pwc.pattern.ReplaceAllStringFunc(censored, func(match string) string { 152 + return strings.Repeat("*", len(match)) 153 + }) 154 + } 155 + return censored 156 + }
+1 -4
pkg/statedb/queue_processor.go
··· 151 return err 152 } 153 scm := chatTask.MessageView 154 - rec, ok := scm.Record.Val.(*streamplace.ChatMessage) 155 - if !ok { 156 - return fmt.Errorf("invalid chat message record") 157 - } 158 159 // Send to webhooks using webhook manager 160 webhooks, err := state.GetActiveWebhooksForUser(rec.Streamer, "chat")
··· 151 return err 152 } 153 scm := chatTask.MessageView 154 + rec := scm.Record.ChatDefs_MessageRecordView 155 156 // Send to webhooks using webhook manager 157 webhooks, err := state.GetActiveWebhooksForUser(rec.Streamer, "chat")
+6 -6
pkg/storage/storage.go
··· 10 "golang.org/x/sync/errgroup" 11 "stream.place/streamplace/pkg/aqtime" 12 "stream.place/streamplace/pkg/config" 13 "stream.place/streamplace/pkg/log" 14 - "stream.place/streamplace/pkg/model" 15 ) 16 17 const moderationRetention = 120 * time.Second 18 19 - func StartSegmentCleaner(ctx context.Context, mod model.Model, cli *config.CLI) error { 20 ctx = log.WithLogValues(ctx, "func", "StartSegmentCleaner") 21 g, ctx := errgroup.WithContext(ctx) 22 g.Go(func() error { ··· 25 case <-ctx.Done(): 26 return nil 27 case <-time.After(60 * time.Second): 28 - expiredSegments, err := mod.GetExpiredSegments(ctx) 29 if err != nil { 30 return err 31 } 32 log.Log(ctx, "Cleaning expired segments", "count", len(expiredSegments)) 33 for _, seg := range expiredSegments { 34 g.Go(func() error { 35 - err := deleteSegment(ctx, mod, cli, seg) 36 if err != nil { 37 log.Error(ctx, "Failed to delete segment", "error", err) 38 } ··· 47 return g.Wait() 48 } 49 50 - func deleteSegment(ctx context.Context, mod model.Model, cli *config.CLI, seg model.Segment) error { 51 if time.Since(seg.StartTime) < moderationRetention { 52 log.Debug(ctx, "Skipping deletion of segment", "id", seg.ID, "time since start", time.Since(seg.StartTime)) 53 return nil ··· 61 if err != nil && !errors.Is(err, os.ErrNotExist) { 62 return err 63 } 64 - err = mod.DeleteSegment(ctx, seg.ID) 65 if err != nil { 66 return err 67 }
··· 10 "golang.org/x/sync/errgroup" 11 "stream.place/streamplace/pkg/aqtime" 12 "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/localdb" 14 "stream.place/streamplace/pkg/log" 15 ) 16 17 const moderationRetention = 120 * time.Second 18 19 + func StartSegmentCleaner(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI) error { 20 ctx = log.WithLogValues(ctx, "func", "StartSegmentCleaner") 21 g, ctx := errgroup.WithContext(ctx) 22 g.Go(func() error { ··· 25 case <-ctx.Done(): 26 return nil 27 case <-time.After(60 * time.Second): 28 + expiredSegments, err := localDB.GetExpiredSegments(ctx) 29 if err != nil { 30 return err 31 } 32 log.Log(ctx, "Cleaning expired segments", "count", len(expiredSegments)) 33 for _, seg := range expiredSegments { 34 g.Go(func() error { 35 + err := deleteSegment(ctx, localDB, cli, seg) 36 if err != nil { 37 log.Error(ctx, "Failed to delete segment", "error", err) 38 } ··· 47 return g.Wait() 48 } 49 50 + func deleteSegment(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, seg localdb.Segment) error { 51 if time.Since(seg.StartTime) < moderationRetention { 52 log.Debug(ctx, "Skipping deletion of segment", "id", seg.ID, "time since start", time.Since(seg.StartTime)) 53 return nil ··· 61 if err != nil && !errors.Is(err, os.ErrNotExist) { 62 return err 63 } 64 + err = localDB.DeleteSegment(ctx, seg.ID) 65 if err != nil { 66 return err 67 }
+44 -1
pkg/streamplace/chatdefs.go
··· 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 ) 14 15 // ChatDefs_MessageView is a "messageView" in the place.stream.chat.defs schema. 16 type ChatDefs_MessageView struct { 17 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` ··· 21 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 24 - Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` 25 ReplyTo *ChatDefs_MessageView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 Uri string `json:"uri" cborgen:"uri"` 27 } 28 29 type ChatDefs_MessageView_ReplyTo struct {
··· 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 ) 14 15 + // ChatDefs_MessageRecordView is a "messageRecordView" in the place.stream.chat.defs schema. 16 + // 17 + // The content of a chat message. 18 + type ChatDefs_MessageRecordView struct { 19 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageRecordView"` 20 + // createdAt: Client-declared timestamp when this message was originally created. 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // facets: Annotations of text (mentions, URLs, etc) 23 + Facets []*RichtextDefs_FacetView `json:"facets,omitempty" cborgen:"facets,omitempty"` 24 + Reply *ChatMessage_ReplyRef `json:"reply,omitempty" cborgen:"reply,omitempty"` 25 + // streamer: The DID of the streamer whose chat this is. 26 + Streamer string `json:"streamer" cborgen:"streamer"` 27 + // text: The primary message content. May be an empty string, if there are embeds. 28 + Text string `json:"text" cborgen:"text"` 29 + } 30 + 31 // ChatDefs_MessageView is a "messageView" in the place.stream.chat.defs schema. 32 type ChatDefs_MessageView struct { 33 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` ··· 37 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 38 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 39 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 40 + Record *ChatDefs_MessageView_Record `json:"record" cborgen:"record"` 41 ReplyTo *ChatDefs_MessageView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 42 Uri string `json:"uri" cborgen:"uri"` 43 + } 44 + 45 + type ChatDefs_MessageView_Record struct { 46 + ChatDefs_MessageRecordView *ChatDefs_MessageRecordView 47 + } 48 + 49 + func (t *ChatDefs_MessageView_Record) MarshalJSON() ([]byte, error) { 50 + if t.ChatDefs_MessageRecordView != nil { 51 + t.ChatDefs_MessageRecordView.LexiconTypeID = "place.stream.chat.defs#messageRecordView" 52 + return json.Marshal(t.ChatDefs_MessageRecordView) 53 + } 54 + return nil, fmt.Errorf("can not marshal empty union as JSON") 55 + } 56 + 57 + func (t *ChatDefs_MessageView_Record) UnmarshalJSON(b []byte) error { 58 + typ, err := lexutil.TypeExtract(b) 59 + if err != nil { 60 + return err 61 + } 62 + 63 + switch typ { 64 + case "place.stream.chat.defs#messageRecordView": 65 + t.ChatDefs_MessageRecordView = new(ChatDefs_MessageRecordView) 66 + return json.Unmarshal(b, t.ChatDefs_MessageRecordView) 67 + default: 68 + return nil 69 + } 70 } 71 72 type ChatDefs_MessageView_ReplyTo struct {
+74
pkg/streamplace/richtextdefs.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.richtext.defs 4 + 5 + package streamplace 6 + 7 + import ( 8 + "encoding/json" 9 + "fmt" 10 + 11 + appbsky "github.com/bluesky-social/indigo/api/bsky" 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + ) 14 + 15 + // RichtextDefs_Censor is a "censor" in the place.stream.richtext.defs schema. 16 + // 17 + // Indicates that the text in the given index has been censored. 18 + type RichtextDefs_Censor struct { 19 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.richtext.defs#censor"` 20 + // categories: Categories of censored content 21 + Categories []string `json:"categories,omitempty" cborgen:"categories,omitempty"` 22 + Reason *string `json:"reason,omitempty" cborgen:"reason,omitempty"` 23 + } 24 + 25 + // RichtextDefs_FacetView is a "facetView" in the place.stream.richtext.defs schema. 26 + // 27 + // Annotation of a sub-string within rich text. 28 + type RichtextDefs_FacetView struct { 29 + Features []*RichtextDefs_FacetView_Features_Elem `json:"features" cborgen:"features"` 30 + Index *appbsky.RichtextFacet_ByteSlice `json:"index" cborgen:"index"` 31 + } 32 + 33 + type RichtextDefs_FacetView_Features_Elem struct { 34 + RichtextFacet_Mention *appbsky.RichtextFacet_Mention 35 + RichtextFacet_Link *appbsky.RichtextFacet_Link 36 + RichtextDefs_Censor *RichtextDefs_Censor 37 + } 38 + 39 + func (t *RichtextDefs_FacetView_Features_Elem) MarshalJSON() ([]byte, error) { 40 + if t.RichtextFacet_Mention != nil { 41 + t.RichtextFacet_Mention.LexiconTypeID = "app.bsky.richtext.facet#mention" 42 + return json.Marshal(t.RichtextFacet_Mention) 43 + } 44 + if t.RichtextFacet_Link != nil { 45 + t.RichtextFacet_Link.LexiconTypeID = "app.bsky.richtext.facet#link" 46 + return json.Marshal(t.RichtextFacet_Link) 47 + } 48 + if t.RichtextDefs_Censor != nil { 49 + t.RichtextDefs_Censor.LexiconTypeID = "place.stream.richtext.defs#censor" 50 + return json.Marshal(t.RichtextDefs_Censor) 51 + } 52 + return nil, fmt.Errorf("can not marshal empty union as JSON") 53 + } 54 + 55 + func (t *RichtextDefs_FacetView_Features_Elem) UnmarshalJSON(b []byte) error { 56 + typ, err := lexutil.TypeExtract(b) 57 + if err != nil { 58 + return err 59 + } 60 + 61 + switch typ { 62 + case "app.bsky.richtext.facet#mention": 63 + t.RichtextFacet_Mention = new(appbsky.RichtextFacet_Mention) 64 + return json.Unmarshal(b, t.RichtextFacet_Mention) 65 + case "app.bsky.richtext.facet#link": 66 + t.RichtextFacet_Link = new(appbsky.RichtextFacet_Link) 67 + return json.Unmarshal(b, t.RichtextFacet_Link) 68 + case "place.stream.richtext.defs#censor": 69 + t.RichtextDefs_Censor = new(RichtextDefs_Censor) 70 + return json.Unmarshal(b, t.RichtextDefs_Censor) 71 + default: 72 + return nil 73 + } 74 + }
+100
pnpm-lock.yaml
··· 479 expo-video: 480 specifier: ^2.0.0 481 version: 2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 482 hls.js: 483 specifier: ^1.5.17 484 version: 1.5.17 ··· 725 sharp: 726 specifier: ^0.32.5 727 version: 0.32.6 728 starlight-openapi: 729 specifier: ^0.17.0 730 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 731 starlight-openapi-rapidoc: 732 specifier: ^0.8.1-beta 733 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 734 streamplace: 735 specifier: workspace:* 736 version: link:../streamplace 737 738 js/streamplace: 739 dependencies: ··· 4903 '@types/normalize-package-data@2.4.4': 4904 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4905 4906 '@types/prop-types@15.7.12': 4907 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} 4908 ··· 7902 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7903 engines: {node: '>=8'} 7904 7905 has-property-descriptors@1.0.2: 7906 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7907 ··· 8351 8352 iron-webcrypto@1.2.1: 8353 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8354 8355 is-alphabetical@2.0.1: 8356 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11754 standard-as-callback@2.1.0: 11755 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11756 11757 starlight-openapi-rapidoc@0.8.1-beta: 11758 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11759 engines: {node: '>=18.14.1'} ··· 11768 '@astrojs/markdown-remark': '>=6.0.1' 11769 '@astrojs/starlight': '>=0.34.0' 11770 astro: '>=5.5.0' 11771 11772 statuses@1.5.0: 11773 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} ··· 11976 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 11977 engines: {node: '>= 8.0'} 11978 11979 supports-color@5.5.0: 11980 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 11981 engines: {node: '>=4'} ··· 11992 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 11993 engines: {node: '>=8'} 11994 11995 supports-preserve-symlinks-flag@1.0.0: 11996 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 11997 engines: {node: '>= 0.4'} ··· 12054 terminal-link@2.1.1: 12055 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12056 engines: {node: '>=8'} 12057 12058 terser-webpack-plugin@5.3.10: 12059 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} ··· 19466 undici-types: 6.21.0 19467 19468 '@types/normalize-package-data@2.4.4': {} 19469 19470 '@types/prop-types@15.7.12': {} 19471 ··· 23318 23319 has-flag@4.0.0: {} 23320 23321 has-property-descriptors@1.0.2: 23322 dependencies: 23323 es-define-property: 1.0.0 ··· 23991 ipaddr.js@2.2.0: {} 23992 23993 iron-webcrypto@1.2.1: {} 23994 23995 is-alphabetical@2.0.1: {} 23996 ··· 28424 28425 standard-as-callback@2.1.0: {} 28426 28427 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28428 dependencies: 28429 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28444 url-template: 3.1.1 28445 transitivePeerDependencies: 28446 - openapi-types 28447 28448 statuses@1.5.0: {} 28449 ··· 28658 transitivePeerDependencies: 28659 - supports-color 28660 28661 supports-color@5.5.0: 28662 dependencies: 28663 has-flag: 3.0.0 ··· 28674 dependencies: 28675 has-flag: 4.0.0 28676 supports-color: 7.2.0 28677 28678 supports-preserve-symlinks-flag@1.0.0: {} 28679 ··· 28769 dependencies: 28770 ansi-escapes: 4.3.2 28771 supports-hyperlinks: 2.3.0 28772 28773 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28774 dependencies:
··· 479 expo-video: 480 specifier: ^2.0.0 481 version: 2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 482 + graphemer: 483 + specifier: ^1.4.0 484 + version: 1.4.0 485 hls.js: 486 specifier: ^1.5.17 487 version: 1.5.17 ··· 728 sharp: 729 specifier: ^0.32.5 730 version: 0.32.6 731 + starlight-links-validator: 732 + specifier: ^0.19.2 733 + version: 0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 734 starlight-openapi: 735 specifier: ^0.17.0 736 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 737 starlight-openapi-rapidoc: 738 specifier: ^0.8.1-beta 739 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 740 + starlight-sidebar-swipe: 741 + specifier: ^0.1.1 742 + version: 0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 743 streamplace: 744 specifier: workspace:* 745 version: link:../streamplace 746 + devDependencies: 747 + starlight-sidebar-topics: 748 + specifier: ^0.6.2 749 + version: 0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 750 751 js/streamplace: 752 dependencies: ··· 4916 '@types/normalize-package-data@2.4.4': 4917 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4918 4919 + '@types/picomatch@3.0.2': 4920 + resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} 4921 + 4922 '@types/prop-types@15.7.12': 4923 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} 4924 ··· 7918 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7919 engines: {node: '>=8'} 7920 7921 + has-flag@5.0.1: 7922 + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} 7923 + engines: {node: '>=12'} 7924 + 7925 has-property-descriptors@1.0.2: 7926 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7927 ··· 8371 8372 iron-webcrypto@1.2.1: 8373 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8374 + 8375 + is-absolute-url@4.0.1: 8376 + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} 8377 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 8378 8379 is-alphabetical@2.0.1: 8380 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11778 standard-as-callback@2.1.0: 11779 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11780 11781 + starlight-links-validator@0.19.2: 11782 + resolution: {integrity: sha512-IHeK3R78fsmv53VfRkGbXkwK1CQEUBHM9QPzBEyoAxjZ/ssi5gjV+F4oNNUppTR48iPp+lEY0MTAmvkX7yNnkw==} 11783 + engines: {node: '>=18.17.1'} 11784 + peerDependencies: 11785 + '@astrojs/starlight': '>=0.32.0' 11786 + astro: '>=5.1.5' 11787 + 11788 starlight-openapi-rapidoc@0.8.1-beta: 11789 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11790 engines: {node: '>=18.14.1'} ··· 11799 '@astrojs/markdown-remark': '>=6.0.1' 11800 '@astrojs/starlight': '>=0.34.0' 11801 astro: '>=5.5.0' 11802 + 11803 + starlight-sidebar-swipe@0.1.1: 11804 + resolution: {integrity: sha512-Q+xv7LSpSLCG3yQaEmZX4Qpks9dcIEc+FBA0Ql+LbLMO9IMBXt8S2zK5wJDhjJn5lbI0i0ipyP375T1GrVS8ig==} 11805 + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} 11806 + peerDependencies: 11807 + '@astrojs/starlight': '>=0.30' 11808 + 11809 + starlight-sidebar-topics@0.6.2: 11810 + resolution: {integrity: sha512-SNCTUZS/hcVor0ZcaXbaSVU37+V+qtvzNirkvnOg3Mqu/awuGpthkH5+uKpiZqWxLffp6TrOlsv5E5QsxrndNg==} 11811 + engines: {node: '>=18'} 11812 + peerDependencies: 11813 + '@astrojs/starlight': '>=0.32.0' 11814 11815 statuses@1.5.0: 11816 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} ··· 12019 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 12020 engines: {node: '>= 8.0'} 12021 12022 + supports-color@10.2.2: 12023 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} 12024 + engines: {node: '>=18'} 12025 + 12026 supports-color@5.5.0: 12027 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 12028 engines: {node: '>=4'} ··· 12039 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 12040 engines: {node: '>=8'} 12041 12042 + supports-hyperlinks@4.4.0: 12043 + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} 12044 + engines: {node: '>=20'} 12045 + 12046 supports-preserve-symlinks-flag@1.0.0: 12047 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 12048 engines: {node: '>= 0.4'} ··· 12105 terminal-link@2.1.1: 12106 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12107 engines: {node: '>=8'} 12108 + 12109 + terminal-link@5.0.0: 12110 + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} 12111 + engines: {node: '>=20'} 12112 12113 terser-webpack-plugin@5.3.10: 12114 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} ··· 19521 undici-types: 6.21.0 19522 19523 '@types/normalize-package-data@2.4.4': {} 19524 + 19525 + '@types/picomatch@3.0.2': {} 19526 19527 '@types/prop-types@15.7.12': {} 19528 ··· 23375 23376 has-flag@4.0.0: {} 23377 23378 + has-flag@5.0.1: {} 23379 + 23380 has-property-descriptors@1.0.2: 23381 dependencies: 23382 es-define-property: 1.0.0 ··· 24050 ipaddr.js@2.2.0: {} 24051 24052 iron-webcrypto@1.2.1: {} 24053 + 24054 + is-absolute-url@4.0.1: {} 24055 24056 is-alphabetical@2.0.1: {} 24057 ··· 28485 28486 standard-as-callback@2.1.0: {} 28487 28488 + starlight-links-validator@0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)): 28489 + dependencies: 28490 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28491 + '@types/picomatch': 3.0.2 28492 + astro: 5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2) 28493 + github-slugger: 2.0.0 28494 + hast-util-from-html: 2.0.3 28495 + hast-util-has-property: 3.0.0 28496 + is-absolute-url: 4.0.1 28497 + kleur: 4.1.5 28498 + mdast-util-mdx-jsx: 3.2.0 28499 + mdast-util-to-string: 4.0.0 28500 + picomatch: 4.0.2 28501 + terminal-link: 5.0.0 28502 + unist-util-visit: 5.0.0 28503 + transitivePeerDependencies: 28504 + - supports-color 28505 + 28506 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28507 dependencies: 28508 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28523 url-template: 3.1.1 28524 transitivePeerDependencies: 28525 - openapi-types 28526 + 28527 + starlight-sidebar-swipe@0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28528 + dependencies: 28529 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28530 + 28531 + starlight-sidebar-topics@0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28532 + dependencies: 28533 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28534 + picomatch: 4.0.2 28535 28536 statuses@1.5.0: {} 28537 ··· 28746 transitivePeerDependencies: 28747 - supports-color 28748 28749 + supports-color@10.2.2: {} 28750 + 28751 supports-color@5.5.0: 28752 dependencies: 28753 has-flag: 3.0.0 ··· 28764 dependencies: 28765 has-flag: 4.0.0 28766 supports-color: 7.2.0 28767 + 28768 + supports-hyperlinks@4.4.0: 28769 + dependencies: 28770 + has-flag: 5.0.1 28771 + supports-color: 10.2.2 28772 28773 supports-preserve-symlinks-flag@1.0.0: {} 28774 ··· 28864 dependencies: 28865 ansi-escapes: 4.3.2 28866 supports-hyperlinks: 2.3.0 28867 + 28868 + terminal-link@5.0.0: 28869 + dependencies: 28870 + ansi-escapes: 7.0.0 28871 + supports-hyperlinks: 4.4.0 28872 28873 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28874 dependencies:
+9
wrangler.toml
···
··· 1 + name = "streamplace-docs" 2 + compatibility_date = "2025-04-01" 3 + 4 + [assets] 5 + directory = "./js/docs/dist/" 6 + not_found_handling = "404-page" 7 + 8 + [build] 9 + command = "pnpm install --filter=streamplace-docs... && cd js/docs && astro build --outDir ./dist/docs && cp ./_redirects ./dist/_redirects && cp ./src/assets/cube.png ./dist/favicon.ico"