Live video on the AT Protocol

Merge pull request #844 from streamplace/eli/red-circles-for-the-people

implement "pre-live" and livestreams ending

authored by

Eli Mallon and committed by
GitHub
e2f4cab8 cccc4fce

+2793 -606
+3 -2
Makefile
··· 384 384 && sed -i.bak 's/AppBskyGraphBlock\.Main/AppBskyGraphBlock\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 385 385 && sed -i.bak 's/PlaceStreamMultistreamTarget\.Main/PlaceStreamMultistreamTarget\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 386 386 && sed -i.bak 's/PlaceStreamChatProfile\.Main/PlaceStreamChatProfile\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream -type f) \ 387 + && sed -i.bak 's/PlaceStreamLivestream\.Main/PlaceStreamLivestream\.Record/' $$(find ./js/streamplace/src/lexicons/types/place/stream/live -type f) \ 387 388 && for x in $$(find ./js/streamplace/src/lexicons -type f -name '*.ts'); do \ 388 389 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 390 done \ ··· 410 411 411 412 .PHONY: lexgen-types 412 413 lexgen-types: 413 - go run github.com/bluesky-social/indigo/cmd/lexgen \ 414 + go tool github.com/bluesky-social/indigo/cmd/lexgen \ 414 415 -outdir ./pkg/spxrpc \ 415 416 --build-file util/lexgen-types.json \ 416 417 --external-lexicons subprojects/atproto/lexicons \ ··· 420 421 .PHONY: lexgen-server 421 422 lexgen-server: 422 423 mkdir -p ./pkg/spxrpc \ 423 - && go run github.com/bluesky-social/indigo/cmd/lexgen \ 424 + && go tool github.com/bluesky-social/indigo/cmd/lexgen \ 424 425 --gen-server \ 425 426 --types-import place.stream:stream.place/streamplace/pkg/streamplace \ 426 427 --types-import app.bsky:github.com/bluesky-social/indigo/api/bsky \
+1 -1
go.mod
··· 8 8 9 9 replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 10 10 11 - //replace github.com/livepeer/go-livepeer => ../go-livepeer 11 + replace github.com/bluesky-social/indigo => github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 12 12 13 13 tool github.com/bluesky-social/indigo/cmd/lexgen 14 14
+2 -2
go.sum
··· 219 219 github.com/bluenviron/gortsplib/v5 v5.2.1/go.mod h1:sK4+00XQaSpU2iPIKjmhj6Yye+sVbNWEU2IJWYEZI9U= 220 220 github.com/bluenviron/mediacommon/v2 v2.5.2 h1:eq7LHJFksDAVtVdTrwOUl7dO7LE8eKwLgYKYi5MmYaY= 221 221 github.com/bluenviron/mediacommon/v2 v2.5.2/go.mod h1:5V15TiOfeaNVmZPVuOqAwqQSWyvMV86/dijDKu5q9Zs= 222 - github.com/bluesky-social/indigo v0.0.0-20251206005924-d49b45419635 h1:kNeRrgGJH2g5OvjLqtaQ744YXqduliZYpFkJ/ld47c0= 223 - github.com/bluesky-social/indigo v0.0.0-20251206005924-d49b45419635/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= 224 222 github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 225 223 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= 226 224 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= ··· 1315 1313 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= 1316 1314 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= 1317 1315 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1316 + github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI= 1317 + github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= 1318 1318 github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= 1319 1319 github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1320 1320 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+6 -2
js/app/components/live-dashboard/bento-grid.tsx
··· 3 3 borders, 4 4 Button, 5 5 Dashboard, 6 + useLivestream, 6 7 useLivestreamStore, 7 8 usePlayerStore, 8 9 useProfile, ··· 81 82 const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); 82 83 const ingestStarted = usePlayerStore((x) => x.ingestStarted); 83 84 const emojiData = useEmojiData(); 85 + const livestream = useLivestream(); 84 86 85 87 // Calculate derived values 86 88 const isConnected = ingestConnectionState === "connected"; ··· 112 114 | "excellent" 113 115 | "good" 114 116 | "poor" 115 - | "offline" => { 117 + | "offline" 118 + | "pre-live" => { 116 119 if (!isLive) return "offline"; 120 + if (!livestream) return "pre-live"; 117 121 switch (segmentTiming.connectionQuality) { 118 122 case "good": 119 123 return "excellent"; ··· 124 128 default: 125 129 return "offline"; 126 130 } 127 - }, [isLive, segmentTiming.connectionQuality]); 131 + }, [isLive, livestream, segmentTiming.connectionQuality]); 128 132 129 133 // Calculate messages per minute 130 134 const messagesPerMinute = useMemo((): number => {
+70 -36
js/app/components/live-dashboard/livestream-panel.tsx
··· 1 1 import { 2 - Admonition, 3 2 Button, 4 3 Checkbox, 5 4 ContentMetadataForm, 6 5 Dashboard, 7 6 formatHandle, 8 - formatHandleWithAt, 9 7 getBlob, 10 8 Input, 11 9 resolveDIDDocument, ··· 13 11 Textarea, 14 12 Tooltip, 15 13 useCreateStreamRecord, 14 + useEndLivestream, 16 15 useLivestream, 17 16 useToast, 18 17 useUpdateStreamRecord, 19 18 useUrl, 20 19 zero, 21 20 } from "@streamplace/components"; 22 - import { ArrowRight, ImagePlus, X } from "lucide-react-native"; 21 + import { ImagePlus, X } from "lucide-react-native"; 23 22 import { useCallback, useEffect, useMemo, useState } from "react"; 24 23 import { 25 24 Image, 26 25 Platform, 27 - Pressable, 28 26 ScrollView, 29 27 TouchableOpacity, 30 28 View, ··· 111 109 r.md, 112 110 layout.flex.center, 113 111 { 114 - height: 200, 112 + height: 100, 115 113 borderStyle: "dashed", 116 114 }, 117 115 ], ··· 184 182 )} 185 183 </> 186 184 )} 187 - <View style={{ marginTop: 8 }}> 185 + {/* <View style={{ marginTop: 8 }}> 188 186 <Admonition variant="info" size="sm"> 189 187 <Text size="sm"> 190 188 You are required to disclose if your content is not suitable for ··· 197 195 </Text> 198 196 </Pressable> 199 197 </Admonition> 200 - </View> 198 + </View> */} 201 199 </View> 202 200 ); 203 201 }; ··· 207 205 const userIsLive = useLiveUser(); 208 206 const captureFrame = useCaptureVideoFrame(); 209 207 const profile = useUserProfile(); 210 - const livestream = useLivestream(); 208 + const livestream = useLivestream(true); 211 209 const createStreamRecord = useCreateStreamRecord(); 212 210 const updateStreamRecord = useUpdateStreamRecord(); 211 + const endLivestream = useEndLivestream(); 213 212 const url = useUrl(); 213 + const [endingLivestream, setEndingLivestream] = useState(false); 214 214 215 215 const [title, setTitle] = useState(""); 216 216 const [loading, setLoading] = useState(false); ··· 222 222 ); 223 223 224 224 const [createPost, setCreatePost] = useState(true); 225 + const [idleTimeout, setIdleTimeout] = useState(true); 225 226 const [sendPushNotification, setSendPushNotification] = useState(true); 226 227 const [canonicalUrl, setCanonicalUrl] = useState<string>(""); 227 228 const defaultCanonicalUrl = useMemo(() => { ··· 256 257 ); 257 258 } 258 259 259 - // Prefill post creation preference 260 260 setCreatePost(typeof livestream.record.post !== "undefined"); 261 261 }, [livestream, defaultCanonicalUrl]); 262 262 ··· 296 296 pushNotification: sendPushNotification, 297 297 }, 298 298 canonicalUrl: canonicalUrl || undefined, 299 + idleTimeoutSeconds: idleTimeout ? 300 : 0, 299 300 }); 300 301 } else { 301 302 await updateStreamRecord( ··· 343 344 title, 344 345 selectedImage, 345 346 mode, 346 - captureFrame, 347 347 createStreamRecord, 348 348 updateStreamRecord, 349 349 livestream, 350 350 ]); 351 351 352 + const handleEndLivestream = useCallback(async () => { 353 + if (!livestream) return; 354 + setEndingLivestream(true); 355 + try { 356 + await endLivestream(); 357 + } catch (error) { 358 + console.error("Error ending livestream:", error); 359 + toast.show("Error", "Failed to end livestream", { 360 + duration: 3, 361 + }); 362 + } 363 + }, [livestream, endLivestream]); 364 + 365 + useEffect(() => { 366 + if (livestream && livestream.record.endedAt !== undefined) { 367 + setEndingLivestream(false); 368 + } 369 + }, [livestream]); 370 + 352 371 const handleImageSelect = useCallback(() => { 353 372 // Default web file picker behavior 354 373 const input = document.createElement("input"); ··· 397 416 ? "Waiting for stream to start..." 398 417 : "Waiting for stream to start..."; 399 418 } 400 - return mode === "create" ? "Announce Livestream!" : "Update Livestream!"; 401 - }, [loading, userIsLive, mode]); 419 + if (!livestream || livestream.record.endedAt !== undefined) { 420 + return "Start Livestream!"; 421 + } 422 + return "Update Livestream!"; 423 + }, [loading, userIsLive, mode, livestream]); 402 424 403 425 const Wrapper = scrollable ? ScrollView : View; 404 426 const wrapperProps = scrollable ··· 409 431 showsVerticalScrollIndicator: false, 410 432 } 411 433 : {}; 434 + 435 + const canEndLivestream = 436 + livestream && livestream.record.endedAt === undefined; 412 437 413 438 return ( 414 439 <> ··· 489 514 { minWidth: 100, textAlign: "left", paddingBottom: 8 }, 490 515 ]} 491 516 > 492 - Streamer 493 - </Text> 494 - <Text 495 - style={[ 496 - text.white, 497 - { fontWeight: "bold", paddingBottom: 8 }, 498 - ]} 499 - > 500 - {profile && formatHandleWithAt(profile)} 501 - </Text> 502 - </View> 503 - <View 504 - style={[ 505 - layout.flex.row, 506 - layout.flex.alignCenter, 507 - w.percent[100], 508 - ]} 509 - > 510 - <Text 511 - style={[ 512 - text.neutral[300], 513 - { minWidth: 100, textAlign: "left", paddingBottom: 8 }, 514 - ]} 515 - > 516 517 Title 517 518 </Text> 518 519 <View style={[flex.values[1]]}> ··· 626 627 style={[{ fontSize: 12 }]} 627 628 /> 628 629 </Tooltip> 630 + 631 + <Tooltip 632 + content="Enabling this setting will turn your livestream off after 5 minutes of inactivity, and you'll need to press the 'Start Livestream' button again to start it again next time you stream." 633 + position="top" 634 + > 635 + <Checkbox 636 + checked={idleTimeout} 637 + onCheckedChange={(checked) => setIdleTimeout(checked)} 638 + label={"End livestream automatically"} 639 + style={[{ fontSize: 12 }]} 640 + /> 641 + </Tooltip> 629 642 </View> 630 643 631 644 {/* Image upload for create mode */} ··· 656 669 style={[text.white, { fontSize: 16, fontWeight: "bold" }]} 657 670 > 658 671 {buttonText} 672 + </Text> 673 + </Button> 674 + <Button 675 + variant={canEndLivestream ? "destructive" : "secondary"} 676 + onPress={handleEndLivestream} 677 + style={[ 678 + r.md, 679 + py[3], 680 + w.percent[100], 681 + layout.flex.center, 682 + { 683 + opacity: !canEndLivestream ? 0.5 : 1, 684 + cursor: canEndLivestream ? "pointer" : "not-allowed", 685 + }, 686 + ]} 687 + disabled={!canEndLivestream || endingLivestream} 688 + > 689 + <Text 690 + style={[text.white, { fontSize: 16, fontWeight: "bold" }]} 691 + > 692 + {endingLivestream ? "Ending Livestream..." : "End Livestream"} 659 693 </Text> 660 694 </Button> 661 695 </View>
+99 -20
js/app/components/live-dashboard/stream-monitor.tsx
··· 5 5 useLivestream, 6 6 useLivestreamStore, 7 7 usePlayerStore, 8 + useSegmentTiming, 8 9 zero, 9 10 } from "@streamplace/components"; 10 11 import { DesktopUi } from "components/mobile/desktop-ui"; ··· 12 13 import { Eye, EyeOff, Signal, Wifi, WifiOff } from "lucide-react-native"; 13 14 import { useEffect, useState } from "react"; 14 15 import { Image, TouchableOpacity, View } from "react-native"; 16 + import Animated from "react-native-reanimated"; 15 17 import { useLiveUser } from "../../hooks/useLiveUser"; 16 - import { useSegmentTiming } from "../../hooks/useSegmentTiming"; 17 18 import StreamScreen from "./live-selector"; 18 19 19 20 const { flex, bg, r, borders, layout, p, text, w, h, mt } = zero; ··· 24 25 videoRef?: any; 25 26 } 26 27 28 + function PreviewOverlay() { 29 + // const opacity = useSharedValue(1); 30 + 31 + // useEffect(() => { 32 + // opacity.value = withRepeat(withTiming(0.8, { duration: 1500 }), -1, true); 33 + // }, [opacity]); 34 + 35 + // const animatedStyle = useAnimatedStyle(() => ({ 36 + // opacity: opacity.value, 37 + // })); 38 + 39 + return ( 40 + <View 41 + style={{ 42 + position: "absolute", 43 + top: 0, 44 + left: 0, 45 + right: 0, 46 + bottom: 0, 47 + justifyContent: "flex-start", 48 + alignItems: "flex-start", 49 + pointerEvents: "none", 50 + }} 51 + > 52 + <Animated.Text 53 + style={[ 54 + // animatedStyle, 55 + { 56 + paddingLeft: 16, 57 + paddingTop: 16, 58 + fontSize: 32, 59 + fontWeight: "800", 60 + color: "white", 61 + letterSpacing: 4, 62 + textShadowColor: "rgba(0, 0, 0, 0.9)", 63 + textShadowOffset: { width: 0, height: 2 }, 64 + textShadowRadius: 8, 65 + }, 66 + ]} 67 + > 68 + PREVIEW (NOT LIVE) 69 + </Animated.Text> 70 + </View> 71 + ); 72 + } 73 + 27 74 export default function StreamMonitor({ 28 75 userProfile: propUserProfile, 29 76 isLive: propIsLive, ··· 33 80 const isUserLive = useLiveUser(); 34 81 const profile = useLivestreamStore((x) => x.profile); 35 82 const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); 36 - const ls = useLivestream(); 83 + let ls = useLivestream(); 37 84 const segmentTiming = useSegmentTiming(); 38 85 39 86 // Use hook data primarily, fallback to props ··· 61 108 // Connection quality indicator 62 109 const getConnectionIcon = () => { 63 110 if (!isLive) return null; 111 + if (!ls) return <Wifi size={16} color="#3b82f6" />; 64 112 65 113 switch (segmentTiming.connectionQuality) { 66 114 case "good": ··· 76 124 77 125 const getConnectionColor = () => { 78 126 if (!isLive) return "red"; 127 + if (!ls) return "blue"; 79 128 80 129 switch (segmentTiming.connectionQuality) { 81 130 case "good": ··· 88 137 return "red"; 89 138 } 90 139 }; 140 + 141 + const getStreamStatus = () => { 142 + if (!isLive) return "OFFLINE"; 143 + if (!ls) return "NOT LIVE"; 144 + return "LIVE"; 145 + }; 146 + 147 + const getStreamTitle = () => { 148 + if (!ls) { 149 + return ( 150 + <Text 151 + style={[ 152 + text.white, 153 + { fontSize: 14, fontWeight: "400", fontStyle: "italic" }, 154 + ]} 155 + numberOfLines={1} 156 + ellipsizeMode="tail" 157 + > 158 + Stream not live yet. Press "Announce Livestream" to start! 159 + </Text> 160 + ); 161 + } 162 + return ( 163 + <Text 164 + style={[text.white, { fontSize: 18, fontWeight: "600" }]} 165 + numberOfLines={1} 166 + ellipsizeMode="tail" 167 + > 168 + {ls?.record.title || "Stream Title"} 169 + </Text> 170 + ); 171 + }; 172 + 91 173 return ( 92 174 <View 93 175 style={[ ··· 104 186 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 187 {isLive && userProfile ? ( 106 188 isStreamVisible ? ( 107 - <Player 108 - src={userProfile.did} 109 - name={userProfile.handle} 110 - muted={true} 189 + <View 190 + style={{ position: "relative", width: "100%", height: "100%" }} 111 191 > 112 - <DesktopUi /> 113 - <PlayerUI.ViewerLoadingOverlay /> 114 - <OfflineCounter isMobile={true} /> 115 - </Player> 192 + <Player 193 + src={userProfile.did} 194 + name={userProfile.handle} 195 + muted={true} 196 + > 197 + <DesktopUi /> 198 + <PlayerUI.ViewerLoadingOverlay /> 199 + <OfflineCounter isMobile={true} /> 200 + </Player> 201 + {!ls && <PreviewOverlay />} 202 + </View> 116 203 ) : ( 117 204 <View 118 205 style={[ ··· 166 253 { flex: 1, minWidth: 0, gap: 12 }, 167 254 ]} 168 255 > 169 - <View style={{ flex: 1, minWidth: 0 }}> 170 - <Text 171 - style={[text.white, { fontSize: 18, fontWeight: "600" }]} 172 - numberOfLines={1} 173 - ellipsizeMode="tail" 174 - > 175 - {ls?.record.title || "Stream Title"} 176 - </Text> 177 - </View> 256 + <View style={{ flex: 1, minWidth: 0 }}>{getStreamTitle()}</View> 178 257 <View 179 258 style={[ 180 259 layout.flex.row, ··· 209 288 ]} 210 289 /> 211 290 <Text style={[text.gray[400], { fontSize: 14 }]}> 212 - {isLive ? "LIVE" : "OFFLINE"} 291 + {getStreamStatus()} 213 292 </Text> 214 293 </View> 215 294 </View>
+1 -7
js/app/components/mobile/desktop-ui.tsx
··· 55 55 setShowCountdown, 56 56 recordSubmitted, 57 57 setRecordSubmitted, 58 - ingestStarting, 59 - setIngestStarting, 60 58 toggleGoLive, 61 59 } = useLivestreamInfo(); 62 60 const { width, height } = usePlayerDimensions(); ··· 113 111 114 112 return () => { 115 113 if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 116 - if (ingestStarting) { 117 - setIngestStarting(false); 118 - } 119 114 }; 120 - }, [ingestStarting, setIngestStarting, resetFadeTimer]); 115 + }, [resetFadeTimer]); 121 116 122 117 const animatedFadeStyle = useAnimatedStyle(() => ({ 123 118 opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, ··· 253 248 <PlayerUI.InputPanel 254 249 title={title} 255 250 setTitle={setTitle} 256 - ingestStarting={ingestStarting} 257 251 toggleGoLive={toggleGoLive} 258 252 isLive={isActivelyLive} 259 253 />
+11 -3
js/app/components/mobile/desktop-ui/live-bubble.tsx
··· 1 - import { Code, useSegment, View, zero } from "@streamplace/components"; 1 + import { 2 + Code, 3 + useLivestream, 4 + useSegment, 5 + View, 6 + zero, 7 + } from "@streamplace/components"; 2 8 import { useMemo } from "react"; 3 9 4 10 const { borders, gap, h, w, px, bg, text } = zero; ··· 6 12 export function LiveBubble() { 7 13 // are we actually live? (is the most recent segment <= 10 seconds old?) 8 14 let seg = useSegment(); 15 + 16 + const livestream = useLivestream(); 9 17 10 18 let segDate = useMemo(() => { 11 19 return seg?.startTime ? new Date(seg.startTime) : undefined; ··· 51 59 { flexDirection: "row", alignItems: "center" }, 52 60 gap.all[1], 53 61 px[2], 54 - bg.destructive[500], 62 + livestream ? bg.destructive[500] : bg.gray[500], 55 63 borders.color.gray[800], 56 64 { paddingVertical: 3 }, 57 65 ]} ··· 67 75 }, 68 76 ]} 69 77 > 70 - LIVE 78 + {livestream ? "LIVE" : "NOT LIVE"} 71 79 </Code> 72 80 </View> 73 81 </View>
+79
js/app/components/mobile/player.tsx
··· 9 9 PlayerUI, 10 10 RotationProvider, 11 11 Text, 12 + useLivestream, 13 + useLivestreamInfo, 12 14 useLivestreamStore, 13 15 usePlayerDimensions, 14 16 usePlayerStore, ··· 131 133 }; 132 134 }, []); 133 135 136 + const livestream = useLivestream(); 137 + const localLivestreamURI = useLivestreamStore((x) => x.localLivestreamURI); 138 + 134 139 if (isStreamingElsewhere) { 135 140 return ( 136 141 <View style={[layout.flex.center, h.percent[100], gap.all[4]]}> ··· 188 193 </View> 189 194 </View> 190 195 ); 196 + } 197 + 198 + if (props.ingest && livestream && livestream.uri !== localLivestreamURI) { 199 + return <LivestreamWarning />; 191 200 } 192 201 193 202 const defaultHandleTeleport = (targetHandle: string, targetDID: string) => { ··· 412 421 </ScrollView> 413 422 ); 414 423 } 424 + 425 + export function LivestreamWarning() { 426 + const livestream = useLivestream(); 427 + const localLivestreamURI = useLivestreamStore((x) => x.localLivestreamURI); 428 + const { toggleStopStream } = useLivestreamInfo(); 429 + const navigation = useNavigation(); 430 + const setLocalLivestreamURI = useLivestreamStore( 431 + (x) => x.setLocalLivestreamURI, 432 + ); 433 + 434 + const [loading, setLoading] = useState(false); 435 + 436 + if (livestream && livestream.uri !== localLivestreamURI) { 437 + return ( 438 + <View style={[layout.flex.center, h.percent[100], gap.all[4]]}> 439 + <Text size="xl">You have an active livestream!</Text> 440 + <Text>"{livestream.record.title}"</Text> 441 + <Button 442 + style={[w.percent[60]]} 443 + onPress={() => { 444 + setLoading(true); 445 + setLocalLivestreamURI(livestream.uri); 446 + }} 447 + disabled={loading} 448 + > 449 + <View 450 + centered 451 + style={[layout.flex.center, layout.flex.row, gap.all[1]]} 452 + > 453 + <Text center>Resume that stream from here</Text> 454 + </View> 455 + </Button> 456 + <Button 457 + style={[w.percent[60]]} 458 + onPress={async () => { 459 + setLoading(true); 460 + try { 461 + await toggleStopStream(); 462 + } catch (error) { 463 + console.error(error); 464 + } finally { 465 + // we want to keep loading until the firehose tells us the stream is stopped 466 + } 467 + }} 468 + variant="destructive" 469 + disabled={loading} 470 + > 471 + <View 472 + centered 473 + style={[layout.flex.center, layout.flex.row, gap.all[1]]} 474 + > 475 + <Text center>End that stream and start a new one</Text> 476 + </View> 477 + </Button> 478 + <Button 479 + variant="secondary" 480 + style={[w.percent[60]]} 481 + onPress={() => navigation.navigate("Home", { screen: "StreamList" })} 482 + > 483 + <View 484 + centered 485 + style={[layout.flex.center, layout.flex.row, gap.all[1]]} 486 + > 487 + <Text>Back</Text> 488 + </View> 489 + </Button> 490 + </View> 491 + ); 492 + } 493 + }
+7 -22
js/app/components/mobile/ui.tsx
··· 7 7 Toast, 8 8 useAvatars, 9 9 useCameraToggle, 10 + useLivestream, 10 11 useLivestreamInfo, 11 12 useLivestreamStore, 12 13 useMuted, ··· 69 70 setShowCountdown, 70 71 recordSubmitted, 71 72 setRecordSubmitted, 72 - ingestStarting, 73 - setIngestStarting, 74 73 toggleGoLive, 75 74 toggleStopStream, 76 75 } = useLivestreamInfo(); ··· 83 82 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 84 83 const muted = useMuted(); 85 84 const setMuted = useSetMuted(); 85 + const ls = useLivestream(); 86 86 87 87 const { shouldShowFloatingMetrics, shouldShowChatSidePanel, chatPanelWidth } = 88 88 useResponsiveLayout(); ··· 90 90 const [showLoading, setShowLoading] = useState(false); 91 91 92 92 useEffect(() => { 93 - return () => { 94 - if (ingestStarting) { 95 - setIngestStarting(false); 96 - } 97 - }; 98 - }, [ingestStarting, setIngestStarting]); 99 - 100 - useEffect(() => { 101 93 if (recordSubmitted) setShowLoading(false); 102 94 }, [recordSubmitted]); 103 95 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]); 96 + const isSelfAndNotLive = ingest !== null && ls === null; 97 + const isSelfAndLive = ingest !== null && ls !== null; 112 98 113 99 const FADE_OUT_DELAY = 4000; 114 100 const fadeOpacity = useSharedValue(1); ··· 225 211 </View> 226 212 </SafeAreaView> 227 213 228 - {shouldShowFloatingMetrics && isLive && ( 214 + {shouldShowFloatingMetrics && isSelfAndLive && ( 229 215 <View 230 216 style={[ 231 217 layout.position.absolute, ··· 246 232 <PlayerUI.InputPanel 247 233 title={title} 248 234 setTitle={setTitle} 249 - ingestStarting={ingestStarting} 250 235 toggleGoLive={toggleGoLive} 251 - isLive={isLive} 236 + isLive={isSelfAndLive} 252 237 /> 253 238 )} 254 239 ··· 281 266 <PlayerUI.AutoplayButton /> 282 267 </View> 283 268 </GestureDetector> 284 - {showChat === undefined && ingest !== "new" && ( 269 + {showChat === undefined && !isSelfAndNotLive && ( 285 270 <MobileChatPanel isPlayerRatioGreater={isPlayerRatioGreater} /> 286 271 )} 287 272 </>
-88
js/app/hooks/useSegmentTiming.tsx
··· 1 - import { useLivestreamStore } from "@streamplace/components"; 2 - import { useEffect, useRef, useState } from "react"; 3 - 4 - export type ConnectionQuality = "good" | "degraded" | "poor"; 5 - 6 - function getLiveConnectionQuality( 7 - timeBetweenSegments: number | null, 8 - range: number | null, 9 - numOfSegments: number = 1, 10 - ): ConnectionQuality { 11 - if (timeBetweenSegments === null || range === null) return "poor"; 12 - 13 - if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 14 - return "good"; 15 - } 16 - if (timeBetweenSegments <= 3000 && range <= (3000 * 60) / numOfSegments) { 17 - return "degraded"; 18 - } 19 - return "poor"; 20 - } 21 - 22 - export function useSegmentTiming() { 23 - const latestSegment = useLivestreamStore((x) => x.segment); 24 - const [segmentDeltas, setSegmentDeltas] = useState<number[]>([]); 25 - const prevSegmentRef = useRef<any>(); 26 - const prevTimestampRef = useRef<number | null>(null); 27 - 28 - // Dummy state to force update every second 29 - const [, setNow] = useState(Date.now()); 30 - 31 - useEffect(() => { 32 - const interval = setInterval(() => { 33 - setNow(Date.now()); 34 - }, 1000); 35 - return () => clearInterval(interval); 36 - }, []); 37 - 38 - useEffect(() => { 39 - if (latestSegment && prevSegmentRef.current !== latestSegment) { 40 - const now = Date.now(); 41 - if (prevTimestampRef.current !== null) { 42 - const delta = now - prevTimestampRef.current; 43 - // Only store the last 25 deltas 44 - setSegmentDeltas((prev) => [...prev, delta].slice(-25)); 45 - } 46 - prevTimestampRef.current = now; 47 - prevSegmentRef.current = latestSegment; 48 - } 49 - }, [latestSegment]); 50 - 51 - // The most recent time between segments 52 - const timeBetweenSegments = 53 - segmentDeltas.length > 0 54 - ? segmentDeltas[segmentDeltas.length - 1] 55 - : prevTimestampRef.current 56 - ? Date.now() - prevTimestampRef.current 57 - : null; 58 - 59 - // Calculate mean and range of deltas 60 - const mean = 61 - segmentDeltas.length > 0 62 - ? Math.round( 63 - segmentDeltas.reduce((acc, curr) => acc + curr, 0) / 64 - segmentDeltas.length, 65 - ) 66 - : null; 67 - 68 - const range = 69 - segmentDeltas.length > 0 70 - ? Math.max(...segmentDeltas) - Math.min(...segmentDeltas) 71 - : null; 72 - 73 - let to_ret = { 74 - segmentDeltas, 75 - timeBetweenSegments, 76 - mean, 77 - range, 78 - connectionQuality: "poor", 79 - }; 80 - 81 - to_ret.connectionQuality = getLiveConnectionQuality( 82 - timeBetweenSegments, 83 - range, 84 - segmentDeltas.length, 85 - ); 86 - 87 - return to_ret; 88 - }
+14 -10
js/app/src/router.tsx
··· 542 542 // are we in the live dashboard? 543 543 const [isLiveDashboard, setIsLiveDashboard] = useState(false); 544 544 useEffect(() => { 545 - if (!isLiveDashboard && userIsLive) { 546 - toast.show("You are live!", "Do you want to go to your Live Dashboard?", { 547 - actionLabel: "Go", 548 - onAction: () => { 549 - navigation.navigate("LiveDashboard"); 550 - setLivePopup(false); 545 + if (!isLiveDashboard && userIsLive && isWeb) { 546 + toast.show( 547 + "You are streaming!", 548 + "Do you want to go to your Live Dashboard?", 549 + { 550 + actionLabel: "Go", 551 + onAction: () => { 552 + navigation.navigate("LiveDashboard"); 553 + setLivePopup(false); 554 + }, 555 + onClose: () => setLivePopup(false), 556 + variant: "error", 557 + duration: 8, 551 558 }, 552 - onClose: () => setLivePopup(false), 553 - variant: "error", 554 - duration: 8, 555 - }); 559 + ); 556 560 } 557 561 }, [userIsLive]); 558 562 const externalItems = useExternalItems();
+7 -3
js/components/src/components/dashboard/header.tsx
··· 8 8 icon: any; 9 9 label: string; 10 10 value: string; 11 - status?: "good" | "warning" | "error"; 11 + status?: "good" | "warning" | "error" | "pre-live"; 12 12 } 13 13 14 14 function MetricItem({ icon: Icon, label, value, status }: MetricItemProps) { ··· 36 36 } 37 37 38 38 interface StatusIndicatorProps { 39 - status: "excellent" | "good" | "poor" | "offline"; 39 + status: "excellent" | "good" | "poor" | "offline" | "pre-live"; 40 40 isLive: boolean; 41 41 } 42 42 ··· 44 44 const getStatusColor = () => { 45 45 if (!isLive) return bg.gray[500]; 46 46 switch (status) { 47 + case "pre-live": 48 + return bg.blue[500]; 47 49 case "excellent": 48 50 return bg.green[500]; 49 51 case "good": ··· 60 62 const getStatusText = () => { 61 63 if (!isLive) return "OFFLINE"; 62 64 switch (status) { 65 + case "pre-live": 66 + return "NOT LIVE"; 63 67 case "excellent": 64 68 return "EXCELLENT"; 65 69 case "good": ··· 101 105 uptime?: string; 102 106 bitrate?: string; 103 107 timeBetweenSegments?: number; 104 - connectionStatus?: "excellent" | "good" | "poor" | "offline"; 108 + connectionStatus?: "excellent" | "good" | "poor" | "offline" | "pre-live"; 105 109 problemsCount?: number; 106 110 onProblemsPress?: () => void; 107 111 }
+15 -9
js/components/src/components/dashboard/information-widget.tsx
··· 13 13 import { LayoutChangeEvent, Text, TouchableOpacity, View } from "react-native"; 14 14 import Svg, { Path, Line as SvgLine, Text as SvgText } from "react-native-svg"; 15 15 import { useAQState } from "../../hooks"; 16 - import { 17 - useLivestreamStore, 18 - useSegment, 19 - useViewers, 20 - } from "../../livestream-store"; 16 + import { useLivestream, useSegment, useViewers } from "../../livestream-store"; 21 17 import * as zero from "../../ui"; 22 18 import { InfoBox, InfoRow } from "../ui"; 23 19 ··· 50 46 const isCompactHeight = layoutMeasured && componentHeight < 350; 51 47 52 48 const seg = useSegment(); 53 - const livestream = useLivestreamStore((x) => x.livestream); 49 + const livestream = useLivestream(); 54 50 const viewers = useViewers(); 55 51 56 52 const getBitrate = useCallback((): number => { ··· 173 169 width: 8, 174 170 height: 8, 175 171 borderRadius: 4, 176 - backgroundColor: 177 - getConnectionStatus() === "good" 172 + backgroundColor: !livestream 173 + ? "#3b82f6" 174 + : getConnectionStatus() === "good" 178 175 ? "#22c55e" 179 176 : getConnectionStatus() === "warning" 180 177 ? "#f59e0b" ··· 182 179 }, 183 180 ]} 184 181 /> 182 + {!livestream && ( 183 + <Text style={[text.blue[400], { fontSize: 13, fontWeight: "600" }]}> 184 + (not live) 185 + </Text> 186 + )} 185 187 </View> 186 188 <TouchableOpacity 187 189 onPress={() => setShowViewers(!showViewers)} ··· 315 317 data={bitrateHistory} 316 318 width={componentWidth - 40} 317 319 height={120} 320 + color={livestream ? "#22c55e" : "#3b82f6"} 318 321 /> 319 322 </View> 320 323 )} ··· 395 398 data={bitrateHistory} 396 399 width={componentWidth - 40} 397 400 height={isCompactHeight ? 80 : 120} 401 + color={livestream ? "#22c55e" : "#3b82f6"} 398 402 /> 399 403 </View> 400 404 )} ··· 432 436 data, 433 437 width, 434 438 height, 439 + color = "#22c55e", 435 440 }: { 436 441 data: number[]; 437 442 width: number; 438 443 height: number; 444 + color?: string; 439 445 }) { 440 446 const maxDataValue = Math.max(...data, 1); 441 447 const minDataValue = Math.min(...data); ··· 515 521 </SvgText> 516 522 <Path 517 523 d={pathData} 518 - stroke="#22c55e" 524 + stroke={color} 519 525 strokeWidth="2" 520 526 fill="none" 521 527 strokeLinecap="round"
+1 -5
js/components/src/components/mobile-player/ui/input.tsx
··· 7 7 type InputPanelProps = { 8 8 title: string | undefined; 9 9 setTitle: (title: string) => void; 10 - ingestStarting: boolean; 11 10 toggleGoLive: () => void; 12 11 isLive: boolean; 13 12 toggleStopStream?: () => void; ··· 16 15 export function InputPanel({ 17 16 title, 18 17 setTitle, 19 - ingestStarting, 20 18 toggleGoLive, 21 19 isLive, 22 20 toggleStopStream, ··· 51 49 /> 52 50 </View> 53 51 )} 54 - {ingestStarting ? ( 55 - <Text>Starting your stream...</Text> 56 - ) : isLive ? ( 52 + {isLive ? ( 57 53 <View style={[layout.flex.center]}> 58 54 <Pressable 59 55 onPress={toggleStopStream}
+17 -2
js/components/src/components/mobile-player/ui/metrics.tsx
··· 12 12 13 13 let icon = <CircleX color="#d44" />; 14 14 let color = "#d44"; 15 - if (connectionQuality === "good") { 15 + if (connectionQuality === "pre-live") { 16 + icon = <CircleCheck color={atoms.colors.blue[500]} />; 17 + color = atoms.colors.blue[500]; 18 + } else if (connectionQuality === "good") { 16 19 icon = <CircleCheck color="#4d4" />; 17 20 color = "#4d4"; 18 21 } else if (connectionQuality === "degraded") { ··· 22 25 icon = <CircleX color="#d44" />; 23 26 color = "#d44"; 24 27 } 28 + 29 + const connectionText = () => { 30 + if (connectionQuality === "pre-live") { 31 + return "READY TO STREAM"; 32 + } else if (connectionQuality === "good") { 33 + return "GOOD"; 34 + } else if (connectionQuality === "degraded") { 35 + return "DEGRADED"; 36 + } else { 37 + return "POOR"; 38 + } 39 + }; 25 40 26 41 return ( 27 42 <View ··· 49 64 }, 50 65 ]} 51 66 > 52 - {connectionQuality.toUpperCase()} 67 + {connectionText()} 53 68 </Text> 54 69 </View> 55 70 {showMetrics && (
+118 -17
js/components/src/components/mobile-player/use-webrtc.tsx
··· 1 1 import { useEffect, useRef, useState } from "react"; 2 2 import * as sdpTransform from "sdp-transform"; 3 - import { PlayerStatus, usePlayerStore, useStreamKey } from "../.."; 3 + import { StreamplaceAgent } from "streamplace"; 4 + import { 5 + PlayerStatus, 6 + usePlayerStore, 7 + usePossiblyUnauthedPDSAgent, 8 + useStreamKey, 9 + } from "../.."; 4 10 import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; 5 11 6 12 export default function useWebRTC( 7 - endpoint: string, 13 + streamer: string, 8 14 ): [MediaStream | null, boolean] { 9 15 const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 10 16 const [stuck, setStuck] = useState<boolean>(false); 11 17 const setStatus = usePlayerStore((x) => x.setStatus); 18 + let agent = usePossiblyUnauthedPDSAgent(); 12 19 13 20 const lastChange = useRef<number>(0); 14 21 15 22 useEffect(() => { 23 + if (!agent) { 24 + return; 25 + } 16 26 const peerConnection = new RTCPeerConnection({ 17 27 bundlePolicy: "max-bundle", 18 28 }); ··· 44 54 } 45 55 }); 46 56 peerConnection.addEventListener("negotiationneeded", () => { 47 - negotiateConnectionWithClientOffer(peerConnection, endpoint); 57 + negotiateConnectionWithClientOffer( 58 + peerConnection, 59 + streamer, 60 + undefined, 61 + agent, 62 + ); 48 63 }); 49 64 50 65 let lastFramesReceived = 0; ··· 82 97 clearInterval(handle); 83 98 peerConnection.close(); 84 99 }; 85 - }, [endpoint]); 100 + }, [streamer, agent]); 86 101 return [mediaStream, stuck]; 87 102 } 88 103 ··· 100 115 */ 101 116 export async function negotiateConnectionWithClientOffer( 102 117 peerConnection: RTCPeerConnection, 118 + streamer: string, 119 + bearerToken?: string, 120 + agent?: StreamplaceAgent, 121 + ) { 122 + /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ 123 + const offer = await peerConnection.createOffer({ 124 + offerToReceiveAudio: true, 125 + offerToReceiveVideo: true, 126 + }); 127 + if (!offer.sdp) { 128 + throw Error("no SDP in offer"); 129 + } 130 + 131 + const newSDP = forceStereoAudio(offer.sdp); 132 + 133 + offer.sdp = newSDP; 134 + /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */ 135 + await peerConnection.setLocalDescription(offer); 136 + 137 + /** Wait for ICE gathering to complete */ 138 + let ofr = await waitToCompleteICEGathering(peerConnection); 139 + if (!ofr) { 140 + throw Error("failed to gather ICE candidates for offer"); 141 + } 142 + 143 + /** 144 + * As long as the connection is open, attempt to... 145 + */ 146 + while (peerConnection.connectionState !== "closed") { 147 + try { 148 + /** 149 + * This response contains the server's SDP offer. 150 + * This specifies how the client should communicate, 151 + * and what kind of media client and server have negotiated to exchange. 152 + */ 153 + let response = await postSDPOffer(streamer, ofr.sdp, bearerToken, agent); 154 + let text = new TextDecoder().decode(response.data); 155 + if (response.success) { 156 + if ((peerConnection.connectionState as string) === "closed") { 157 + return; 158 + } 159 + await peerConnection.setRemoteDescription( 160 + new RTCSessionDescription({ type: "answer", sdp: text }), 161 + ); 162 + return "https://stream.place/example"; 163 + } else { 164 + console.error(text); 165 + } 166 + } catch (e) { 167 + console.error(`posting sdp offer failed: ${e}`); 168 + } 169 + 170 + /** Limit reconnection attempts to at-most once every 5 seconds */ 171 + await new Promise((r) => setTimeout(r, 5000)); 172 + } 173 + } 174 + 175 + export async function negotiateIngestConnectionWithClientOffer( 176 + peerConnection: RTCPeerConnection, 103 177 endpoint: string, 104 - bearerToken?: string, 178 + bearerToken: string, 105 179 ) { 106 180 /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ 107 181 const offer = await peerConnection.createOffer({ ··· 134 208 * This specifies how the client should communicate, 135 209 * and what kind of media client and server have negotiated to exchange. 136 210 */ 137 - let response = await postSDPOffer(`${endpoint}`, ofr.sdp, bearerToken); 211 + let response = await postSDPIngestOffer(endpoint, ofr.sdp, bearerToken); 212 + 138 213 if (response.status === 201) { 139 - let answerSDP = await response.text(); 140 214 if ((peerConnection.connectionState as string) === "closed") { 141 215 return; 142 216 } 143 217 await peerConnection.setRemoteDescription( 144 - new RTCSessionDescription({ type: "answer", sdp: answerSDP }), 145 - ); 146 - return response.headers.get("Location"); 147 - } else if (response.status === 405) { 148 - console.log( 149 - "Remember to update the URL passed into the WHIP or WHEP client", 218 + new RTCSessionDescription({ 219 + type: "answer", 220 + sdp: await response.text(), 221 + }), 150 222 ); 223 + return "https://stream.place/example"; 151 224 } else { 152 - const errorMessage = await response.text(); 153 - console.error(errorMessage); 225 + console.error(await response.text()); 154 226 } 155 227 } catch (e) { 156 228 console.error(`posting sdp offer failed: ${e}`); ··· 162 234 } 163 235 164 236 async function postSDPOffer( 165 - endpoint: string, 237 + streamer: string, 166 238 data: string, 167 239 bearerToken?: string, 240 + agent?: StreamplaceAgent, 241 + ) { 242 + if (!agent) { 243 + throw new Error("No agent found"); 244 + } 245 + return await agent.place.stream.playback.whep(data, { 246 + qp: { 247 + rendition: "source", 248 + streamer: streamer, 249 + }, 250 + }); 251 + // return await fetch(endpoint, { 252 + // method: "POST", 253 + // mode: "cors", 254 + // headers: { 255 + // "content-type": "application/sdp", 256 + // ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), 257 + // }, 258 + // body: data, 259 + // }); 260 + } 261 + 262 + async function postSDPIngestOffer( 263 + endpoint: string, 264 + data: string, 265 + bearerToken: string, 168 266 ) { 169 267 return await fetch(endpoint, { 170 268 method: "POST", ··· 254 352 } 255 353 }); 256 354 peerConnection.addEventListener("negotiationneeded", (ev) => { 257 - negotiateConnectionWithClientOffer( 355 + if (!storedKey?.streamKey?.privateKey) { 356 + throw new Error("no private key found"); 357 + } 358 + negotiateIngestConnectionWithClientOffer( 258 359 peerConnection, 259 360 endpoint, 260 361 storedKey.streamKey?.privateKey,
+7 -5
js/components/src/components/mobile-player/video-async.native.tsx
··· 285 285 export function NativeIngestPlayer(props?: { 286 286 objectFit?: "contain" | "cover"; 287 287 }) { 288 - const ingestStarting = useIngestPlayerStore((x) => x.ingestStarting); 289 288 const ingestMediaSource = useIngestPlayerStore((x) => x.ingestMediaSource); 290 289 const ingestAutoStart = useIngestPlayerStore((x) => x.ingestAutoStart); 290 + const setIngestLive = useIngestPlayerStore((x) => x.setIngestLive); 291 291 const setStatus = useIngestPlayerStore((x) => x.setStatus); 292 292 const setVideoRef = usePlayerStore((x) => x.setVideoRef); 293 293 ··· 377 377 }, [ingestMediaSource, ingestCamera]); 378 378 379 379 useEffect(() => { 380 - if (!ingestStarting && !ingestAutoStart) { 381 - setRemoteMediaStream(null); 382 - return; 380 + if (localMediaStream) { 381 + setIngestLive(true); 383 382 } 383 + }, [localMediaStream]); 384 + 385 + useEffect(() => { 384 386 if (!localMediaStream) { 385 387 return; 386 388 } 387 389 console.log("setting remote media stream", localMediaStream); 388 390 // @ts-expect-error: WebRTCMediaStream may not have all MediaStream properties, but is compatible for our use 389 391 setRemoteMediaStream(localMediaStream); 390 - }, [localMediaStream, ingestStarting, ingestAutoStart, setRemoteMediaStream]); 392 + }, [localMediaStream, ingestAutoStart, setRemoteMediaStream]); 391 393 392 394 if (!localMediaStream) { 393 395 return null;
+10 -7
js/components/src/components/mobile-player/video.tsx
··· 442 442 443 443 const status = usePlayerStore((x) => x.status); 444 444 const setStatus = usePlayerStore((x) => x.setStatus); 445 + const src = usePlayerStore((x) => x.src); 445 446 446 447 const playerEvent = usePlayerStore((x) => x.playerEvent); 447 448 const spurl = useStreamplaceStore((x) => x.url); 448 449 449 - const [mediaStream, stuck] = useWebRTC(url); 450 + const [mediaStream, stuck] = useWebRTC(src); 450 451 451 452 useEffect(() => { 452 453 if (stuck) { ··· 541 542 } 542 543 543 544 export function WebcamIngestPlayer(props: VideoProps) { 544 - const ingestStarting = usePlayerStore((x) => x.ingestStarting); 545 545 const ingestMediaSource = usePlayerStore((x) => x.ingestMediaSource); 546 546 const ingestAutoStart = usePlayerStore((x) => x.ingestAutoStart); 547 + const setIngestLive = usePlayerStore((x) => x.setIngestLive); 547 548 548 549 const [error, setError] = useState<Error | null>(null); 549 550 ··· 606 607 }, [ingestMediaSource]); 607 608 608 609 useEffect(() => { 609 - if (!ingestStarting && !ingestAutoStart) { 610 - setRemoteMediaStream(null); 611 - return; 612 - } 610 + // if (!ingestAutoStart) { 611 + // setRemoteMediaStream(null); 612 + // return; 613 + // } 613 614 if (!localMediaStream) { 614 615 return; 615 616 } 617 + console.log("setting remote media stream", localMediaStream); 618 + setIngestLive(true); 616 619 setRemoteMediaStream(localMediaStream); 617 - }, [localMediaStream, ingestStarting, ingestAutoStart]); 620 + }, [localMediaStream, setIngestLive, setRemoteMediaStream]); 618 621 619 622 useEffect(() => { 620 623 if (!videoElement) {
+15 -24
js/components/src/hooks/useLivestreamInfo.ts
··· 1 1 import { useState } from "react"; 2 2 import { useLivestreamStore } from "../livestream-store"; 3 3 import { usePlayerStore } from "../player-store"; 4 - import { useCreateStreamRecord } from "../streamplace-store"; 4 + import { useCreateStreamRecord, useEndLivestream } from "../streamplace-store"; 5 5 6 6 export function useLivestreamInfo(url?: string) { 7 7 const ingest = usePlayerStore((x) => x.ingestConnectionState); 8 8 const profile = useLivestreamStore((x) => x.profile); 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 - 9 + const endLivestream = useEndLivestream(); 10 + const setLocalLivestreamURI = useLivestreamStore( 11 + (x) => x.setLocalLivestreamURI, 12 + ); 14 13 const createStreamRecord = useCreateStreamRecord(); 15 14 16 15 const [title, setTitle] = useState<string>(""); ··· 22 21 if (title !== "") { 23 22 setRecordSubmitted(true); 24 23 // Create the livestream record with title and custom url if available 25 - await createStreamRecord({ 24 + const { uri } = await createStreamRecord({ 26 25 title, 27 26 canonicalUrl: url || undefined, 28 27 }); 28 + setLocalLivestreamURI(uri); 29 29 } 30 30 } catch (error) { 31 31 console.error("Error creating livestream:", error); ··· 39 39 keyboardHeight?: number, 40 40 closeKeyboard?: () => void, 41 41 ) => { 42 - if (!ingestStarting) { 43 - // Optionally close keyboard if provided 44 - if (closeKeyboard) closeKeyboard(); 45 - setShowCountdown(true); 46 - setIngestStarting(true); 47 - setIngestLive(true); 48 - // wait ~3 seconds before announcing 49 - setTimeout(() => { 50 - handleSubmit(); 51 - }, 3000); 52 - } else { 53 - setIngestStarting(false); 54 - setIngestLive(false); 55 - } 42 + // Optionally close keyboard if provided 43 + if (closeKeyboard) closeKeyboard(); 44 + setShowCountdown(true); 45 + // wait ~3 seconds before announcing 46 + setTimeout(() => { 47 + handleSubmit(); 48 + }, 3000); 56 49 }; 57 50 58 51 // Stop the current broadcast 59 52 const toggleStopStream = () => { 60 53 console.log("Stopping stream..."); 61 - stopIngest(); 54 + endLivestream(); 62 55 }; 63 56 64 57 return { ··· 70 63 setShowCountdown, 71 64 recordSubmitted, 72 65 setRecordSubmitted, 73 - ingestStarting, 74 - setIngestStarting, 75 66 handleSubmit, 76 67 toggleGoLive, 77 68 toggleStopStream,
+7 -2
js/components/src/hooks/useSegmentTiming.tsx
··· 1 1 import { useEffect, useRef, useState } from "react"; 2 - import { useLivestreamStore } from "../livestream-store"; 2 + import { useLivestream, useLivestreamStore } from "../livestream-store"; 3 3 4 - export type ConnectionQuality = "good" | "degraded" | "poor"; 4 + export type ConnectionQuality = "good" | "degraded" | "poor" | "pre-live"; 5 5 6 6 function getLiveConnectionQuality( 7 7 timeBetweenSegments: number | null, ··· 24 24 const [segmentDeltas, setSegmentDeltas] = useState<number[]>([]); 25 25 const prevSegmentRef = useRef<any>(); 26 26 const prevTimestampRef = useRef<number | null>(null); 27 + const ls = useLivestream(); 27 28 28 29 // Dummy state to force update every second 29 30 const [, setNow] = useState(Date.now()); ··· 83 84 range, 84 85 segmentDeltas.length, 85 86 ); 87 + 88 + if (!ls) { 89 + to_ret.connectionQuality = "pre-live"; 90 + } 86 91 87 92 return to_ret; 88 93 }
+2
js/components/src/livestream-store/livestream-state.tsx
··· 32 32 setModerationPermissions: ( 33 33 permissions: PlaceStreamModerationPermission.Record[], 34 34 ) => void; 35 + localLivestreamURI: string | null; 36 + setLocalLivestreamURI: (uri: string | null) => void; 35 37 } 36 38 37 39 export interface LivestreamProblem {
+9 -1
js/components/src/livestream-store/livestream-store.tsx
··· 29 29 hasReceivedSegment: false, 30 30 moderationPermissions: [], 31 31 setModerationPermissions: (perms) => set({ moderationPermissions: perms }), 32 + localLivestreamURI: null, 33 + setLocalLivestreamURI: (uri) => set({ localLivestreamURI: uri }), 32 34 })); 33 35 }; 34 36 ··· 62 64 63 65 export const useViewers = () => useLivestreamStore((x) => x.viewers); 64 66 65 - export const useLivestream = () => useLivestreamStore((x) => x.livestream); 67 + export const useLivestream = (includeEnded: boolean = false) => 68 + useLivestreamStore((x) => { 69 + const ls = x.livestream; 70 + if (!ls) return null; 71 + if (!includeEnded && ls.record.endedAt !== undefined) return null; 72 + return ls; 73 + }); 66 74 67 75 export const useSegment = () => useLivestreamStore((x) => x.segment); 68 76
+1 -7
js/components/src/player-store/player-state.tsx
··· 32 32 protocol: PlayerProtocol; 33 33 setProtocol: (protocol: PlayerProtocol) => void; 34 34 35 - /** Source */ 35 + /** Source (streamer did) */ 36 36 src: string; 37 37 38 38 /** Function to set the source URL */ 39 39 setSrc: (src: string) => void; 40 - 41 - /** Flag indicating if ingest (stream input) is currently starting */ 42 - ingestStarting: boolean; 43 - 44 - /** Function to set the ingestStarting flag */ 45 - setIngestStarting: (ingestStarting: boolean) => void; 46 40 47 41 /** Flag indicating if ingest is live */ 48 42 ingestLive: boolean;
+1 -5
js/components/src/player-store/player-store.tsx
··· 28 28 src: "", 29 29 setSrc: (src: string) => set(() => ({ src })), 30 30 31 - ingestStarting: false, 32 - setIngestStarting: (ingestStarting: boolean) => 33 - set(() => ({ ingestStarting })), 34 - 35 31 ingestMediaSource: undefined, 36 32 setIngestMediaSource: (ingestMediaSource: IngestMediaSource | undefined) => 37 33 set(() => ({ ingestMediaSource })), ··· 45 41 ingestConnectionState: RTCPeerConnectionState | null, 46 42 ) => set(() => ({ ingestConnectionState })), 47 43 48 - ingestAutoStart: false, 44 + ingestAutoStart: true, 49 45 setIngestAutoStart: (ingestAutoStart: boolean) => 50 46 set(() => ({ ingestAutoStart })), 51 47
-4
js/components/src/player-store/single-player-provider.tsx
··· 143 143 * Hook to get the ingest state of the current player 144 144 */ 145 145 export function useCurrentPlayerIngest(): { 146 - starting: boolean; 147 - setStarting: (starting: boolean) => void; 148 146 connectionState: RTCPeerConnectionState | null; 149 147 setConnectionState: (state: RTCPeerConnectionState | null) => void; 150 148 startedTimestamp: number | null; 151 149 setStartedTimestamp: (timestamp: number | null) => void; 152 150 } { 153 151 return useCurrentPlayerStore((state) => ({ 154 - starting: state.ingestStarting, 155 - setStarting: state.setIngestStarting, 156 152 connectionState: state.ingestConnectionState, 157 153 setConnectionState: state.setIngestConnectionState, 158 154 startedTimestamp: state.ingestStarted,
+42 -99
js/components/src/streamplace-store/stream.tsx
··· 127 127 let agent = usePDSAgent(); 128 128 let url = useUrl(); 129 129 const uploadThumbnail = useUploadThumbnail(); 130 - 131 130 return async ({ 132 131 title, 133 132 customThumbnail, 134 133 submitPost, 135 134 canonicalUrl, 136 135 notificationSettings, 136 + idleTimeoutSeconds, 137 137 }: { 138 138 title: string; 139 139 customThumbnail?: Blob; 140 140 submitPost?: boolean; 141 141 canonicalUrl?: string; 142 142 notificationSettings?: PlaceStreamLivestream.NotificationSettings; 143 + idleTimeoutSeconds?: number; 143 144 }) => { 144 - if (typeof submitPost !== "boolean") { 145 - submitPost = true; 146 - } 147 145 if (!agent) { 148 146 throw new Error("No PDS agent found"); 149 147 } 150 148 151 - if (!agent.did) { 152 - throw new Error("No user DID found, assuming not logged in"); 153 - } 154 - 155 - const u = new URL(url); 156 - 157 - let thumbnail: BlobRef | undefined = undefined; 158 - 159 - if (customThumbnail) { 160 - try { 161 - thumbnail = await uploadThumbnail(agent, customThumbnail); 162 - } catch (e) { 163 - throw new Error(`Custom thumbnail upload failed ${e}`); 164 - } 165 - } else { 166 - // No custom thumbnail: fetch the server-side image and upload it 167 - // try thrice lel 168 - let tries = 0; 169 - try { 170 - for (; tries < 3; tries++) { 171 - try { 172 - console.log( 173 - `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, 174 - ); 175 - const thumbnailRes = await fetch( 176 - `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, 177 - ); 178 - if (!thumbnailRes.ok) { 179 - throw new Error( 180 - `Failed to fetch thumbnail: ${thumbnailRes.status})`, 181 - ); 182 - } 183 - const thumbnailBlob = await thumbnailRes.blob(); 184 - console.log(thumbnailBlob); 185 - thumbnail = await uploadThumbnail(agent, thumbnailBlob); 186 - } catch (e) { 187 - console.warn( 188 - `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, 189 - ); 190 - // Wait 1 second before retrying 191 - await new Promise((resolve) => setTimeout(resolve, 2000)); 192 - if (tries === 2) { 193 - throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); 194 - } 195 - } 196 - } 197 - } catch (e) { 198 - throw new Error(`Thumbnail upload failed ${e}`); 199 - } 200 - } 201 - 202 - let newPost: undefined | { uri: string; cid: string } = undefined; 203 - 204 - const did = agent.did; 205 - const profile = await agent.getProfile({ actor: did }); 206 - 207 - if (submitPost) { 208 - if (!profile) { 209 - throw new Error("No profile found for the user DID"); 210 - } 211 - 212 - const params = new URLSearchParams({ 213 - did: did, 214 - time: new Date().toISOString(), 215 - }); 216 - 217 - let post = await buildGoLivePost( 218 - title, 219 - u, 220 - profile.data, 221 - params, 222 - thumbnail, 223 - agent, 224 - ); 225 - 226 - newPost = await createNewPost(agent, post); 227 - 228 - if (!newPost.uri || !newPost.cid) { 229 - throw new Error( 230 - "Cannot read properties of undefined (reading 'uri' or 'cid')", 231 - ); 232 - } 233 - } 234 - 235 149 let platform: string = Platform.OS; 236 150 let platVersion: string = Platform.Version 237 151 ? Platform.Version.toString() ··· 244 158 ) { 245 159 platVersion = getBrowserName(window.navigator.userAgent); 246 160 } 247 - 248 - const thisUrl = `${url}/${profile.data.handle}`; 249 - if (!canonicalUrl) { 250 - canonicalUrl = thisUrl; 161 + if (!agent.did) { 162 + throw new Error("No user DID found, assuming not logged in"); 251 163 } 164 + 165 + const thisUrl = `${url}/${agent.did}`; 252 166 253 167 const record: PlaceStreamLivestream.Record = { 254 168 $type: "place.stream.livestream", 255 169 title: title, 256 170 url: thisUrl, 257 171 createdAt: new Date().toISOString(), 172 + lastSeenAt: new Date().toISOString(), 258 173 // would match up with e.g. https://stream.place/iame.li 259 174 canonicalUrl: canonicalUrl, 260 175 // user agent style string 261 176 // e.g. `@streamplace/components/0.1.0 (ios, 32.0)` 262 177 agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, 263 - post: newPost, 264 - thumb: thumbnail, 178 + idleTimeoutSeconds: idleTimeoutSeconds, 265 179 }; 266 180 267 181 if (notificationSettings) { 268 182 record.notificationSettings = notificationSettings; 269 183 } 270 184 271 - await agent.com.atproto.repo.createRecord({ 272 - repo: agent.did, 273 - collection: "place.stream.livestream", 274 - record, 185 + if (customThumbnail) { 186 + try { 187 + const thumbnail = await uploadThumbnail(agent, customThumbnail); 188 + record.thumb = thumbnail; 189 + } catch (e) { 190 + throw new Error(`Custom thumbnail upload failed ${e}`); 191 + } 192 + } 193 + 194 + const output = await agent.place.stream.live.startLivestream({ 195 + livestream: record, 196 + streamer: agent.did, 197 + createBlueskyPost: submitPost, 275 198 }); 276 - return record; 199 + 200 + if (!output.success) { 201 + throw new Error("Failed to start livestream"); 202 + } 203 + 204 + return output.data; 277 205 }; 278 206 } 279 207 ··· 339 267 return record; 340 268 }; 341 269 } 270 + 271 + export function useEndLivestream() { 272 + let agent = usePDSAgent(); 273 + return async () => { 274 + if (!agent) { 275 + throw new Error("No PDS agent found"); 276 + } 277 + 278 + if (!agent.did) { 279 + throw new Error("No user DID found, assuming not logged in"); 280 + } 281 + 282 + return await agent.place.stream.live.stopLivestream({}); 283 + }; 284 + }
+1
js/desktop/src/tests/playback-test.ts
··· 12 12 env: { 13 13 ...testEnv.env, 14 14 SP_TEST_STREAM: "true", 15 + SP_WIDE_OPEN: "true", 15 16 }, 16 17 }; 17 18 },
+1
js/desktop/src/tests/resume-loop-test.ts
··· 19 19 env: { 20 20 ...testEnv.env, 21 21 SP_TEST_STREAM: "true", 22 + SP_WIDE_OPEN: "true", 22 23 }, 23 24 }; 24 25 },
+3
js/desktop/src/tests/server-restart-test.ts
··· 29 29 const env = { 30 30 SP_HTTP_ADDR: `127.0.0.1:${randomPort()}`, 31 31 SP_HTTP_INTERNAL_ADDR: `127.0.0.1:${randomPort()}`, 32 + SP_RTMP_ADDR: `127.0.0.1:${randomPort()}`, 32 33 SP_DATA_DIR: tmpDir, 33 34 SP_TEST_STREAM: "true", 35 + SP_NO_FIREHOSE: "true", 36 + SP_WIDE_OPEN: "true", 34 37 }; 35 38 let { proc } = await makeNode({ 36 39 env: env,
+1
js/desktop/src/tests/test-runner.ts
··· 71 71 SP_HTTP_INTERNAL_ADDR: `127.0.0.1:${randomPort()}`, 72 72 SP_RTMP_ADDR: `127.0.0.1:${randomPort()}`, 73 73 SP_DATA_DIR: tmpDir, 74 + SP_NO_FIREHOSE: "true", 74 75 }; 75 76 } 76 77 if (test.setup) {
+101
js/docs/src/content/docs/lex-reference/live/place-stream-live-startlivestream.md
··· 1 + --- 2 + title: place.stream.live.startLivestream 3 + description: Reference for the place.stream.live.startLivestream 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 + Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such. 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 + | `livestream` | [`place.stream.livestream`](/lex-reference/place-stream-livestream) | ✅ | | | 30 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 31 + | `createBlueskyPost` | `boolean` | ❌ | Whether to create a Bluesky post announcing the livestream. | | 32 + 33 + **Output:** 34 + 35 + - **Encoding:** `application/json` 36 + - **Schema:** 37 + 38 + **Schema Type:** `object` 39 + 40 + | Name | Type | Req'd | Description | Constraints | 41 + | ----- | -------- | ----- | --------------------------------- | ------------- | 42 + | `uri` | `string` | ✅ | The URI of the livestream record. | Format: `uri` | 43 + | `cid` | `string` | ✅ | The CID of the livestream record. | Format: `cid` | 44 + 45 + --- 46 + 47 + ## Lexicon Source 48 + 49 + ```json 50 + { 51 + "lexicon": 1, 52 + "id": "place.stream.live.startLivestream", 53 + "defs": { 54 + "main": { 55 + "type": "procedure", 56 + "description": "Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such.", 57 + "input": { 58 + "encoding": "application/json", 59 + "schema": { 60 + "type": "object", 61 + "required": ["streamer", "livestream"], 62 + "properties": { 63 + "livestream": { 64 + "type": "ref", 65 + "ref": "place.stream.livestream" 66 + }, 67 + "streamer": { 68 + "type": "string", 69 + "format": "did", 70 + "description": "The DID of the streamer." 71 + }, 72 + "createBlueskyPost": { 73 + "type": "boolean", 74 + "description": "Whether to create a Bluesky post announcing the livestream." 75 + } 76 + } 77 + } 78 + }, 79 + "output": { 80 + "encoding": "application/json", 81 + "schema": { 82 + "type": "object", 83 + "required": ["uri", "cid"], 84 + "properties": { 85 + "uri": { 86 + "type": "string", 87 + "format": "uri", 88 + "description": "The URI of the livestream record." 89 + }, 90 + "cid": { 91 + "type": "string", 92 + "format": "cid", 93 + "description": "The CID of the livestream record." 94 + } 95 + } 96 + } 97 + } 98 + } 99 + } 100 + } 101 + ```
+82
js/docs/src/content/docs/lex-reference/live/place-stream-live-stoplivestream.md
··· 1 + --- 2 + title: place.stream.live.stopLivestream 3 + description: Reference for the place.stream.live.stopLivestream 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 + Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video. 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Input:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + _(No properties defined)_ 28 + **Output:** 29 + 30 + - **Encoding:** `application/json` 31 + - **Schema:** 32 + 33 + **Schema Type:** `object` 34 + 35 + | Name | Type | Req'd | Description | Constraints | 36 + | ----- | -------- | ----- | --------------------------------------------- | ------------- | 37 + | `uri` | `string` | ✅ | The URI of the stopped livestream record. | Format: `uri` | 38 + | `cid` | `string` | ✅ | The new CID of the stopped livestream record. | Format: `cid` | 39 + 40 + --- 41 + 42 + ## Lexicon Source 43 + 44 + ```json 45 + { 46 + "lexicon": 1, 47 + "id": "place.stream.live.stopLivestream", 48 + "defs": { 49 + "main": { 50 + "type": "procedure", 51 + "description": "Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video.", 52 + "input": { 53 + "encoding": "application/json", 54 + "schema": { 55 + "type": "object", 56 + "required": [], 57 + "properties": {} 58 + } 59 + }, 60 + "output": { 61 + "encoding": "application/json", 62 + "schema": { 63 + "type": "object", 64 + "required": ["uri", "cid"], 65 + "properties": { 66 + "uri": { 67 + "type": "string", 68 + "format": "uri", 69 + "description": "The URI of the stopped livestream record." 70 + }, 71 + "cid": { 72 + "type": "string", 73 + "format": "cid", 74 + "description": "The new CID of the stopped livestream record." 75 + } 76 + } 77 + } 78 + } 79 + } 80 + } 81 + } 82 + ```
+174
js/docs/src/content/docs/lex-reference/openapi.json
··· 517 517 } 518 518 } 519 519 }, 520 + "/xrpc/place.stream.playback.whep": { 521 + "post": { 522 + "summary": "Play a stream over WebRTC using WHEP.", 523 + "operationId": "place.stream.playback.whep", 524 + "tags": ["place.stream.playback"], 525 + "responses": { 526 + "200": { 527 + "description": "Success", 528 + "content": { 529 + "*/*": { 530 + "schema": {} 531 + } 532 + } 533 + }, 534 + "400": { 535 + "description": "Bad Request", 536 + "content": { 537 + "application/json": { 538 + "schema": { 539 + "type": "object", 540 + "required": ["error", "message"], 541 + "properties": { 542 + "error": { 543 + "type": "string", 544 + "oneOf": [ 545 + { 546 + "const": "Unauthorized" 547 + } 548 + ] 549 + }, 550 + "message": { 551 + "type": "string" 552 + } 553 + } 554 + } 555 + } 556 + } 557 + } 558 + }, 559 + "parameters": [ 560 + { 561 + "name": "streamer", 562 + "in": "query", 563 + "required": true, 564 + "description": "The DID of the streamer to play.", 565 + "schema": { 566 + "type": "string", 567 + "description": "The DID of the streamer to play." 568 + } 569 + }, 570 + { 571 + "name": "rendition", 572 + "in": "query", 573 + "required": true, 574 + "description": "The rendition of the stream to play.", 575 + "schema": { 576 + "type": "string", 577 + "description": "The rendition of the stream to play." 578 + } 579 + } 580 + ], 581 + "requestBody": { 582 + "required": true, 583 + "content": { 584 + "*/*": { 585 + "schema": {} 586 + } 587 + } 588 + } 589 + } 590 + }, 520 591 "/xrpc/place.stream.multistream.createTarget": { 521 592 "post": { 522 593 "summary": "Create a new target for rebroadcasting a Streamplace stream.", ··· 1480 1551 } 1481 1552 } 1482 1553 ] 1554 + } 1555 + }, 1556 + "/xrpc/place.stream.live.startLivestream": { 1557 + "post": { 1558 + "summary": "Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such.", 1559 + "operationId": "place.stream.live.startLivestream", 1560 + "tags": ["place.stream.live"], 1561 + "responses": { 1562 + "200": { 1563 + "description": "Success", 1564 + "content": { 1565 + "application/json": { 1566 + "schema": { 1567 + "type": "object", 1568 + "properties": { 1569 + "uri": { 1570 + "type": "string", 1571 + "description": "The URI of the livestream record.", 1572 + "format": "uri" 1573 + }, 1574 + "cid": { 1575 + "type": "string", 1576 + "description": "The CID of the livestream record.", 1577 + "format": "cid" 1578 + } 1579 + }, 1580 + "required": ["uri", "cid"] 1581 + } 1582 + } 1583 + } 1584 + } 1585 + }, 1586 + "requestBody": { 1587 + "required": true, 1588 + "content": { 1589 + "application/json": { 1590 + "schema": { 1591 + "type": "object", 1592 + "properties": { 1593 + "livestream": { 1594 + "$ref": "#/components/schemas/place.stream.livestream" 1595 + }, 1596 + "streamer": { 1597 + "type": "string", 1598 + "description": "The DID of the streamer.", 1599 + "format": "did" 1600 + }, 1601 + "createBlueskyPost": { 1602 + "type": "boolean", 1603 + "description": "Whether to create a Bluesky post announcing the livestream." 1604 + } 1605 + }, 1606 + "required": ["streamer", "livestream"] 1607 + } 1608 + } 1609 + } 1610 + } 1611 + } 1612 + }, 1613 + "/xrpc/place.stream.live.stopLivestream": { 1614 + "post": { 1615 + "summary": "Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video.", 1616 + "operationId": "place.stream.live.stopLivestream", 1617 + "tags": ["place.stream.live"], 1618 + "responses": { 1619 + "200": { 1620 + "description": "Success", 1621 + "content": { 1622 + "application/json": { 1623 + "schema": { 1624 + "type": "object", 1625 + "properties": { 1626 + "uri": { 1627 + "type": "string", 1628 + "description": "The URI of the stopped livestream record.", 1629 + "format": "uri" 1630 + }, 1631 + "cid": { 1632 + "type": "string", 1633 + "description": "The new CID of the stopped livestream record.", 1634 + "format": "cid" 1635 + } 1636 + }, 1637 + "required": ["uri", "cid"] 1638 + } 1639 + } 1640 + } 1641 + } 1642 + }, 1643 + "requestBody": { 1644 + "required": true, 1645 + "content": { 1646 + "application/json": { 1647 + "schema": { 1648 + "type": "object", 1649 + "properties": {} 1650 + } 1651 + } 1652 + } 1653 + } 1483 1654 } 1484 1655 }, 1485 1656 "/xrpc/place.stream.live.subscribeSegments": { ··· 3420 3591 } 3421 3592 }, 3422 3593 "required": ["did", "handle"] 3594 + }, 3595 + "place.stream.livestream": { 3596 + "description": "Unknown type" 3423 3597 }, 3424 3598 "place.stream.live.subscribeSegments_segment": { 3425 3599 "type": "string",
+17
js/docs/src/content/docs/lex-reference/place-stream-livestream.md
··· 24 24 | `title` | `string` | ✅ | The title of the livestream, as it will be announced to followers. | Max Length: 1400<br/>Max Graphemes: 140 | 25 25 | `url` | `string` | ❌ | The URL where this stream can be found. This is primarily a hint for other Streamplace nodes to locate and replicate the stream. | Format: `uri` | 26 26 | `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` | 27 + | `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the Streamplace station. | Format: `datetime` | 28 + | `endedAt` | `string` | ❌ | Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. | Format: `datetime` | 29 + | `idleTimeoutSeconds` | `integer` | ❌ | Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. | | 27 30 | `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | | 28 31 | `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: `<product> / <product-version> <comment>` e.g. Streamplace/0.7.5 iOS | | 29 32 | `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | ··· 156 159 "type": "string", 157 160 "format": "datetime", 158 161 "description": "Client-declared timestamp when this livestream started." 162 + }, 163 + "lastSeenAt": { 164 + "type": "string", 165 + "format": "datetime", 166 + "description": "Client-declared timestamp when this livestream was last seen by the Streamplace station." 167 + }, 168 + "endedAt": { 169 + "type": "string", 170 + "format": "datetime", 171 + "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again." 172 + }, 173 + "idleTimeoutSeconds": { 174 + "type": "integer", 175 + "description": "Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout." 159 176 }, 160 177 "post": { 161 178 "type": "ref",
+82
js/docs/src/content/docs/lex-reference/playback/place-stream-playback-whep.md
··· 1 + --- 2 + title: place.stream.playback.whep 3 + description: Reference for the place.stream.playback.whep 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 + Play a stream over WebRTC using WHEP. 17 + 18 + **Parameters:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ----------- | -------- | ----- | ------------------------------------ | ----------- | 22 + | `streamer` | `string` | ✅ | The DID of the streamer to play. | | 23 + | `rendition` | `string` | ✅ | The rendition of the stream to play. | | 24 + 25 + **Input:** 26 + 27 + - **Encoding:** `*/*` 28 + - **Schema:** 29 + 30 + _Schema not defined._ 31 + **Output:** 32 + 33 + - **Encoding:** `*/*` 34 + - **Schema:** 35 + 36 + _Schema not defined._ 37 + **Possible Errors:** 38 + 39 + - `Unauthorized`: This user may not play this stream. 40 + 41 + --- 42 + 43 + ## Lexicon Source 44 + 45 + ```json 46 + { 47 + "lexicon": 1, 48 + "id": "place.stream.playback.whep", 49 + "defs": { 50 + "main": { 51 + "type": "procedure", 52 + "description": "Play a stream over WebRTC using WHEP.", 53 + "parameters": { 54 + "type": "params", 55 + "required": ["streamer", "rendition"], 56 + "properties": { 57 + "streamer": { 58 + "type": "string", 59 + "description": "The DID of the streamer to play." 60 + }, 61 + "rendition": { 62 + "type": "string", 63 + "description": "The rendition of the stream to play." 64 + } 65 + } 66 + }, 67 + "input": { 68 + "encoding": "*/*" 69 + }, 70 + "output": { 71 + "encoding": "*/*" 72 + }, 73 + "errors": [ 74 + { 75 + "name": "Unauthorized", 76 + "description": "This user may not play this stream." 77 + } 78 + ] 79 + } 80 + } 81 + } 82 + ```
+51
lexicons/place/stream/live/startLivestream.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.startLivestream", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new place.stream.livestream record, automatically populating a thumbnail and creating a Bluesky post and whatnot. You can do this manually by creating a record but this method can work better for mobile livestreaming and such.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "livestream"], 13 + "properties": { 14 + "livestream": { 15 + "type": "ref", 16 + "ref": "place.stream.livestream" 17 + }, 18 + "streamer": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "The DID of the streamer." 22 + }, 23 + "createBlueskyPost": { 24 + "type": "boolean", 25 + "description": "Whether to create a Bluesky post announcing the livestream." 26 + } 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["uri", "cid"], 35 + "properties": { 36 + "uri": { 37 + "type": "string", 38 + "format": "uri", 39 + "description": "The URI of the livestream record." 40 + }, 41 + "cid": { 42 + "type": "string", 43 + "format": "cid", 44 + "description": "The CID of the livestream record." 45 + } 46 + } 47 + } 48 + } 49 + } 50 + } 51 + }
+37
lexicons/place/stream/live/stopLivestream.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.live.stopLivestream", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Stop your current livestream, updating your current place.stream.livestream record and ceasing the flow of video.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [], 13 + "properties": {} 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "schema": { 19 + "type": "object", 20 + "required": ["uri", "cid"], 21 + "properties": { 22 + "uri": { 23 + "type": "string", 24 + "format": "uri", 25 + "description": "The URI of the stopped livestream record." 26 + }, 27 + "cid": { 28 + "type": "string", 29 + "format": "cid", 30 + "description": "The new CID of the stopped livestream record." 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+14
lexicons/place/stream/livestream.json
··· 26 26 "format": "datetime", 27 27 "description": "Client-declared timestamp when this livestream started." 28 28 }, 29 + "lastSeenAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "Client-declared timestamp when this livestream was last seen by the Streamplace station." 33 + }, 34 + "endedAt": { 35 + "type": "string", 36 + "format": "datetime", 37 + "description": "Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again." 38 + }, 39 + "idleTimeoutSeconds": { 40 + "type": "integer", 41 + "description": "Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout." 42 + }, 29 43 "post": { 30 44 "type": "ref", 31 45 "ref": "com.atproto.repo.strongRef",
+36
lexicons/place/stream/playback/whep.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.playback.whep", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Play a stream over WebRTC using WHEP.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["streamer", "rendition"], 11 + "properties": { 12 + "streamer": { 13 + "type": "string", 14 + "description": "The DID of the streamer to play." 15 + }, 16 + "rendition": { 17 + "type": "string", 18 + "description": "The rendition of the stream to play." 19 + } 20 + } 21 + }, 22 + "input": { 23 + "encoding": "*/*" 24 + }, 25 + "output": { 26 + "encoding": "*/*" 27 + }, 28 + "errors": [ 29 + { 30 + "name": "Unauthorized", 31 + "description": "This user may not play this stream." 32 + } 33 + ] 34 + } 35 + } 36 + }
+5 -1
pkg/api/api.go
··· 155 155 Recorder: metrics.NewRecorder(metrics.Config{}), 156 156 }) 157 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) 158 + xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB, a.MediaManager, a.Aliases) 159 159 if err != nil { 160 160 return nil, err 161 161 } ··· 660 660 livestream, err := a.Model.GetLatestLivestreamForRepo(repoDID) 661 661 if err != nil { 662 662 apierrors.WriteHTTPInternalServerError(w, "could not get livestream", err) 663 + return 664 + } 665 + if livestream == nil { 666 + apierrors.WriteHTTPNotFound(w, "no livestream found", nil) 663 667 return 664 668 } 665 669
+28 -2
pkg/api/playback.go
··· 16 16 "stream.place/streamplace/pkg/errors" 17 17 "stream.place/streamplace/pkg/log" 18 18 "stream.place/streamplace/pkg/spmetrics" 19 + "stream.place/streamplace/pkg/streamplace" 19 20 ) 20 21 21 22 func (a *StreamplaceAPI) NormalizeUser(ctx context.Context, user string) (string, error) { ··· 28 29 return user, nil 29 30 } 30 31 // only other allowed case is a bluesky handle 31 - repo, err := a.ATSync.SyncBlueskyRepoCached(ctx, user, a.Model) 32 + repo, err := a.ATSync.SyncBlueskyRepoCached(ctx, user) 32 33 if err != nil { 33 34 return "", err 34 35 } ··· 56 57 offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} 57 58 var answer *webrtc.SessionDescription 58 59 if a.CLI.NewWebRTCPlayback { 59 - answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer) 60 + answer, err = a.MediaManager.WebRTCPlayback2(ctx, user, rendition, &offer, "") 60 61 } else { 61 62 answer, err = a.MediaManager.WebRTCPlayback(ctx, user, rendition, &offer) 62 63 } ··· 271 272 if err != nil { 272 273 errors.WriteHTTPNotFound(w, "user not found", err) 273 274 return 275 + } 276 + if !a.CLI.WideOpen { 277 + ls, err := a.Model.GetLatestLivestreamForRepo(user) 278 + if err != nil { 279 + errors.WriteHTTPInternalServerError(w, "could not get livestream", err) 280 + return 281 + } 282 + if ls == nil { 283 + errors.WriteHTTPNotFound(w, "livestream not found", err) 284 + return 285 + } 286 + lsrv, err := ls.ToLivestreamView() 287 + if err != nil { 288 + errors.WriteHTTPInternalServerError(w, "could not marshal livestream", err) 289 + return 290 + } 291 + lsr, ok := lsrv.Record.Val.(*streamplace.Livestream) 292 + if !ok { 293 + errors.WriteHTTPInternalServerError(w, "livestream is not a streamplace livestream", nil) 294 + return 295 + } 296 + if lsr.EndedAt != nil { 297 + errors.WriteHTTPNotFound(w, "livestream has ended", nil) 298 + return 299 + } 274 300 } 275 301 thumb, err := a.LocalDB.LatestThumbnailForUser(user) 276 302 if err != nil {
+5
pkg/api/websocket.go
··· 220 220 log.Error(ctx, "could not get latest livestream", "error", err) 221 221 return 222 222 } 223 + if ls == nil { 224 + log.Error(ctx, "no livestream found", "repoDID", repoDID) 225 + return 226 + } 223 227 lsv, err := ls.ToLivestreamView() 224 228 if err != nil { 225 229 log.Error(ctx, "could not marshal livestream", "error", err) ··· 262 266 tp := teleports[0] 263 267 if tp.Repo == nil { 264 268 log.Error(ctx, "teleportee repo is nil", "uri", tp.URI) 269 + return 265 270 } 266 271 viewerCount := a.Bus.GetViewerCount(tp.RepoDID) 267 272 arrivalMsg := streamplace.Livestream_TeleportArrival{
+3 -3
pkg/atproto/atproto.go
··· 22 22 23 23 var SyncGetRepo = comatproto.SyncGetRepo 24 24 25 - func (atsync *ATProtoSynchronizer) SyncBlueskyRepoCached(ctx context.Context, handle string, mod model.Model) (*model.Repo, error) { 25 + func (atsync *ATProtoSynchronizer) SyncBlueskyRepoCached(ctx context.Context, handle string) (*model.Repo, error) { 26 26 ctx, span := otel.Tracer("signer").Start(ctx, "SyncBlueskyRepoCached") 27 27 defer span.End() 28 - repo, err := mod.GetRepoByHandleOrDID(handle) 28 + repo, err := atsync.Model.GetRepoByHandleOrDID(handle) 29 29 if err != nil { 30 30 return nil, fmt.Errorf("failed to get repo for %s: %w", handle, err) 31 31 } ··· 33 33 return repo, nil 34 34 } 35 35 36 - return atsync.SyncBlueskyRepo(ctx, handle, mod) 36 + return atsync.SyncBlueskyRepo(ctx, handle, atsync.Model) 37 37 } 38 38 39 39 type mstNode struct {
+3
pkg/atproto/firehose.go
··· 18 18 lexutil "github.com/bluesky-social/indigo/lex/util" 19 19 "github.com/bluesky-social/indigo/repo" 20 20 "github.com/bluesky-social/indigo/repomgr" 21 + "github.com/streamplace/oatproxy/pkg/oatproxy" 21 22 "golang.org/x/sync/errgroup" 22 23 "stream.place/streamplace/pkg/aqhttp" 23 24 "stream.place/streamplace/pkg/aqtime" ··· 44 45 Bus *bus.Bus 45 46 PLCDirectory identity.Directory 46 47 CachedPLCDirectory identity.Directory 48 + OATProxy *oatproxy.OATProxy 47 49 } 48 50 49 51 func (atsync *ATProtoSynchronizer) StartFirehose(ctx context.Context) error { ··· 161 163 constants.APP_BSKY_GRAPH_FOLLOW, 162 164 constants.APP_BSKY_FEED_POST, 163 165 constants.APP_BSKY_GRAPH_BLOCK, 166 + constants.APP_BSKY_ACTOR_PROFILE, 164 167 constants.PLACE_STREAM_LIVE_RECOMMENDATIONS, 165 168 } 166 169
+1
pkg/atproto/lexicon_permission_sets.go
··· 31 31 "repo?collection=app.bsky.actor.status", 32 32 "repo?collection=app.bsky.graph.block", 33 33 "repo?collection=app.bsky.graph.follow", 34 + "repo?collection=app.bsky.actor.profile", 34 35 "rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 35 36 "rpc:app.bsky.actor.getProfiles?aud=did:web:api.bsky.app%23bsky_appview", 36 37 "include:place.stream.authFull",
+3 -3
pkg/atproto/lexicon_repo.go
··· 195 195 return priv.HashAndSign(sb) 196 196 } 197 197 198 - events, err := state.GetCommitEventsSince(cli.MyDID(), time.Time{}) 198 + events, err := state.GetCommitEventsSince(cli.BroadcasterDID(), time.Time{}) 199 199 if err != nil { 200 200 return nil, fmt.Errorf("failed to get commit events: %w", err) 201 201 } ··· 222 222 if err != nil { 223 223 return nil, fmt.Errorf("failed to create delta session: %w", err) 224 224 } 225 - LexiconRepo = atrepo.NewRepo(ctx, cli.MyDID(), ses) 225 + LexiconRepo = atrepo.NewRepo(ctx, cli.BroadcasterDID(), ses) 226 226 } else { 227 227 LexiconRepo, err = atrepo.OpenRepo(ctx, ses, currentRoot) 228 228 if err != nil { ··· 316 316 if len(ops) > 0 { 317 317 log.Log(ctx, "created new lexicon commit for changes", "did", signed.Did, "data", signed.Data, "prev", signed.Prev, "rev", signed.Rev) 318 318 commit := &comatproto.SyncSubscribeRepos_Commit{ 319 - Repo: cli.MyDID(), 319 + Repo: cli.BroadcasterDID(), 320 320 Blocks: blocks, 321 321 Rev: currentRev, 322 322 Commit: lexutil.LexLink(currentRoot),
+5 -5
pkg/atproto/lexicon_repo_test.go
··· 40 40 require.NotNil(t, rec) 41 41 handle.Close() 42 42 43 - evts, err := state.GetCommitEventsSinceSeq(cli.MyDID(), 0) 43 + evts, err := state.GetCommitEventsSinceSeq(cli.BroadcasterDID(), 0) 44 44 require.NoError(t, err) 45 45 require.Len(t, evts, 1) 46 - require.Equal(t, evts[0].RepoDID, cli.MyDID()) 46 + require.Equal(t, evts[0].RepoDID, cli.BroadcasterDID()) 47 47 48 48 // opening an existing repo 49 49 handle, err = MakeLexiconRepo(context.Background(), &cli, mod, state) ··· 100 100 require.NoError(t, err) 101 101 handle.Close() 102 102 103 - evts, err = state.GetCommitEventsSinceSeq(cli.MyDID(), 0) 103 + evts, err = state.GetCommitEventsSinceSeq(cli.BroadcasterDID(), 0) 104 104 require.NoError(t, err) 105 105 require.Len(t, evts, 2) 106 - require.Equal(t, evts[0].RepoDID, cli.MyDID()) 107 - require.Equal(t, evts[1].RepoDID, cli.MyDID()) 106 + require.Equal(t, evts[0].RepoDID, cli.BroadcasterDID()) 107 + require.Equal(t, evts[1].RepoDID, cli.BroadcasterDID()) 108 108 oldCommit, err := evts[0].ToCommitEvent() 109 109 require.NoError(t, err) 110 110 newCommit, err := evts[1].ToCommitEvent()
+1 -1
pkg/atproto/migrate.go
··· 60 60 currentDID := did 61 61 g.Go(func() error { 62 62 log.Debug(ctx, "syncing repo", "did", currentDID, "progress", currentIndex+1, "total", len(allDIDs)) 63 - _, err := atsync.SyncBlueskyRepoCached(ctx, currentDID, atsync.Model) 63 + _, err := atsync.SyncBlueskyRepoCached(ctx, currentDID) 64 64 if err != nil { 65 65 log.Error(ctx, "failed to sync repo", "did", currentDID, "err", err) 66 66 syncErrorMu.Lock()
+45
pkg/atproto/profile.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + 8 + "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/xrpc" 10 + ) 11 + 12 + var ErrUserNotFound = errors.New("user not found") 13 + 14 + func (atsync *ATProtoSynchronizer) FetchUserProfile(ctx context.Context, username string) (*bsky.ActorDefs_ProfileViewDetailed, error) { 15 + // Use ATSync to resolve username to DID, then fetch full profile from Bluesky 16 + var actor string 17 + 18 + // First try to resolve via internal DB 19 + repo, err := atsync.Model.GetRepoByHandleOrDID(username) 20 + if err != nil { 21 + return nil, fmt.Errorf("%w: %w", ErrUserNotFound, err) 22 + } else if repo != nil { 23 + // Use the DID as it's the most reliable identifier 24 + actor = repo.DID 25 + } else { 26 + return nil, fmt.Errorf("no repo found for username: %s (%w)", username, ErrUserNotFound) 27 + } 28 + 29 + // Fetch full profile from Bluesky public API 30 + client := &xrpc.Client{ 31 + Host: "https://public.api.bsky.app", 32 + } 33 + 34 + profile, err := bsky.ActorGetProfile(ctx, client, actor) 35 + if err != nil { 36 + return nil, fmt.Errorf("failed to fetch profile from Bluesky for '%s': %w", actor, err) 37 + } 38 + 39 + if profile == nil { 40 + return nil, fmt.Errorf("received nil profile from Bluesky API for '%s'", actor) 41 + } 42 + 43 + return profile, nil 44 + 45 + }
+42 -30
pkg/atproto/sync.go
··· 12 12 "github.com/bluesky-social/indigo/api/bsky" 13 13 "github.com/bluesky-social/indigo/atproto/atdata" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/util" 15 16 "stream.place/streamplace/pkg/aqtime" 17 + "stream.place/streamplace/pkg/constants" 16 18 "stream.place/streamplace/pkg/log" 17 19 "stream.place/streamplace/pkg/model" 18 20 "stream.place/streamplace/pkg/statedb" ··· 83 85 } 84 86 go atsync.Bus.Publish(userDID, streamplaceBlock) 85 87 88 + case *bsky.ActorProfile: 89 + if r == nil { 90 + // someone we don't know about 91 + return nil 92 + } 93 + wasStreamplace, _ := d[constants.BlueskyProfileGoliveKey].(bool) 94 + err := atsync.Model.UpsertBskyProfile(ctx, aturi, *recCBOR, wasStreamplace) 95 + if err != nil { 96 + return fmt.Errorf("failed to upsert bsky profile: %w", err) 97 + } 98 + 86 99 case *streamplace.ChatMessage: 87 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 100 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 88 101 if err != nil { 89 102 return fmt.Errorf("failed to sync bluesky repo: %w", err) 90 103 } 91 104 92 105 go func() { 93 - _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer, atsync.Model) 106 + _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer) 94 107 if err != nil { 95 108 log.Error(ctx, "failed to sync bluesky repo", "err", err) 96 109 } ··· 173 186 } 174 187 175 188 case *streamplace.ChatGate: 176 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 189 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 177 190 if err != nil { 178 191 return fmt.Errorf("failed to sync bluesky repo: %w", err) 179 192 } ··· 205 218 go atsync.Bus.Publish(userDID, streamplaceGate) 206 219 207 220 case *streamplace.ChatProfile: 208 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 221 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 209 222 if err != nil { 210 223 return fmt.Errorf("failed to sync bluesky repo: %w", err) 211 224 } ··· 220 233 } 221 234 222 235 case *streamplace.ServerSettings: 223 - _, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 236 + _, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 224 237 if err != nil { 225 238 return fmt.Errorf("failed to sync bluesky repo: %w", err) 226 239 } ··· 248 261 } 249 262 250 263 if livestream, ok := d["place.stream.livestream"]; ok { 251 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 264 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 252 265 if err != nil { 253 266 return fmt.Errorf("failed to sync bluesky repo: %w", err) 254 267 } ··· 287 300 // log.Warn(ctx, "chat message detected", "uri", livestream.URI) 288 301 // if this post is a reply to someone's livestream post 289 302 // log.Warn(ctx, "chat message detected", "message", rec.Text) 290 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 303 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 291 304 if err != nil { 292 305 return fmt.Errorf("failed to sync bluesky repo: %w", err) 293 306 } ··· 362 375 } 363 376 go atsync.Bus.Publish(userDID, lsv) 364 377 365 - var postView *bsky.FeedDefs_PostView 366 - if lsHydrated.Post != nil { 367 - postView, err = lsHydrated.Post.ToBskyPostView() 378 + if !isFirstSync { 379 + // queue a task to clean up the livestream if it's been inactive for too long 380 + task := &statedb.FinalizeLivestreamTask{ 381 + LivestreamURI: aturi.String(), 382 + } 383 + if rec.LastSeenAt == nil || rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 || rec.EndedAt != nil { 384 + return nil 385 + } 386 + scheduledAt, err := time.Parse(time.RFC3339, *rec.LastSeenAt) 368 387 if err != nil { 369 - return fmt.Errorf("failed to convert livestream post to bsky post view: %w", err) 388 + log.Error(ctx, "failed to parse last seen at", "err", err) 389 + return nil 370 390 } 371 - } 372 391 373 - task := &statedb.NotificationTask{ 374 - Livestream: lsv, 375 - FeedPost: postView, 376 - PDSURL: r.PDS, 377 - } 378 - 379 - cp, err := atsync.Model.GetChatProfile(ctx, userDID) 380 - if err != nil { 381 - return fmt.Errorf("failed to get chat profile: %w", err) 382 - } 383 - if cp != nil { 384 - spcp, err := cp.ToStreamplaceChatProfile() 392 + // if we check after exactly rec.IdleTimeoutSeconds we might miss the finalization by a few seconds 393 + scheduledAt = scheduledAt.Add((time.Duration(*rec.IdleTimeoutSeconds) * time.Second) + (10 * time.Second)).UTC() 394 + taskKey := fmt.Sprintf("finalize-livestream::%s::%s", aturi.String(), scheduledAt.Format(util.ISO8601)) 395 + log.Warn(ctx, "queueing stream finalization task", "taskKey", taskKey, "scheduledAt", scheduledAt) 396 + _, err = atsync.StatefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestream, task, statedb.WithTaskKey(taskKey), statedb.WithScheduledAt(scheduledAt)) 385 397 if err != nil { 386 - return fmt.Errorf("failed to convert chat profile to streamplace chat profile: %w", err) 398 + return fmt.Errorf("failed to enqueue remove red circle task: %w", err) 387 399 } 388 - task.ChatProfile = spcp 400 + 389 401 } 390 402 391 403 case *streamplace.LiveTeleport: ··· 483 495 } 484 496 485 497 case *streamplace.BroadcastOrigin: 486 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 498 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 487 499 if err != nil { 488 500 return fmt.Errorf("failed to sync broadcast origin creator bluesky repo: %w", err) 489 501 } 490 - _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer, atsync.Model) 502 + _, err = atsync.SyncBlueskyRepoCached(ctx, rec.Streamer) 491 503 if err != nil { 492 504 return fmt.Errorf("failed to sync broadcast origin streamer bluesky repo: %w", err) 493 505 } ··· 508 520 go atsync.Bus.Publish("", view) 509 521 510 522 case *streamplace.MetadataConfiguration: 511 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 523 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 512 524 if err != nil { 513 525 return fmt.Errorf("failed to sync bluesky repo: %w", err) 514 526 } ··· 524 536 } 525 537 526 538 case *streamplace.ModerationPermission: 527 - repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 539 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID) 528 540 if err != nil { 529 541 return fmt.Errorf("failed to sync bluesky repo: %w", err) 530 542 }
+1
pkg/bus/segchanman.go
··· 16 16 Filepath string 17 17 Data []byte 18 18 PacketizedData *PacketizedSegment 19 + Published bool 19 20 } 20 21 21 22 type PacketizedSegment struct {
+26 -14
pkg/cmd/streamplace.go
··· 215 215 } 216 216 cli.AccessJWK = accessJWK 217 217 218 + serviceAuthKey, err := state.EnsureServiceAuthKey(ctx) 219 + if err != nil { 220 + return err 221 + } 222 + cli.ServiceAuthKey = serviceAuthKey 223 + 218 224 b := bus.NewBus() 219 225 atsync := &atproto.ATProtoSynchronizer{ 220 226 CLI: cli, ··· 266 272 } 267 273 } 268 274 275 + op := oatproxy.New(&oatproxy.Config{ 276 + Host: host, 277 + CreateOAuthSession: state.CreateOAuthSession, 278 + UpdateOAuthSession: state.UpdateOAuthSession, 279 + GetOAuthSession: state.LoadOAuthSession, 280 + Lock: state.GetNamedLock, 281 + Scope: atproto.OAuthString, 282 + UpstreamJWK: cli.JWK, 283 + DownstreamJWK: cli.AccessJWK, 284 + ClientMetadata: clientMetadata, 285 + Public: cli.PublicOAuth, 286 + }) 287 + state.OATProxy = op 288 + 289 + err = atsync.Migrate(ctx) 290 + if err != nil { 291 + return fmt.Errorf("failed to migrate: %w", err) 292 + } 293 + 269 294 var replicator replication.Replicator = nil 270 295 if slices.Contains(cli.Replicators, config.ReplicatorIroh) { 271 296 exists, err := cli.DataFileExists([]string{"iroh-kv-secret"}) ··· 305 330 replicator = websocketrep.NewWebsocketReplicator(b, mod, mm) 306 331 } 307 332 308 - op := oatproxy.New(&oatproxy.Config{ 309 - Host: host, 310 - CreateOAuthSession: state.CreateOAuthSession, 311 - UpdateOAuthSession: state.UpdateOAuthSession, 312 - GetOAuthSession: state.LoadOAuthSession, 313 - Lock: state.GetNamedLock, 314 - Scope: atproto.OAuthString, 315 - UpstreamJWK: cli.JWK, 316 - DownstreamJWK: cli.AccessJWK, 317 - ClientMetadata: clientMetadata, 318 - Public: cli.PublicOAuth, 319 - HTTPClient: &aqhttp.Client, 320 - }) 321 - d := director.NewDirector(mm, mod, cli, b, op, state, replicator, ldb) 333 + d := director.NewDirector(mm, mod, cli, b, op, state, replicator, ldb, atsync) 322 334 a, err := api.MakeStreamplaceAPI(cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) 323 335 if err != nil { 324 336 return err
+6 -1
pkg/config/config.go
··· 116 116 RateLimitWebsocket int 117 117 JWK jwk.Key 118 118 AccessJWK jwk.Key 119 + ServiceAuthKey jwk.Key 119 120 dataDirFlags []*string 120 121 DiscordWebhooks []*discordtypes.Webhook 121 122 NewWebRTCPlayback bool ··· 1152 1153 return fmt.Errorf("user is not allowed to stream") 1153 1154 } 1154 1155 1155 - func (cli *CLI) MyDID() string { 1156 + func (cli *CLI) BroadcasterDID() string { 1156 1157 return fmt.Sprintf("did:web:%s", cli.BroadcasterHost) 1158 + } 1159 + 1160 + func (cli *CLI) ServerDID() string { 1161 + return fmt.Sprintf("did:web:%s", cli.ServerHost) 1157 1162 } 1158 1163 1159 1164 func (cli *CLI) HasHTTPS() bool {
+3
pkg/constants/constants.go
··· 11 11 var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all 12 12 var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all 13 13 var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 14 + var APP_BSKY_ACTOR_PROFILE = "app.bsky.actor.profile" //nolint:all 14 15 var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 15 16 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 16 17 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all ··· 75 76 WarningC2PASuffering = "cwarn:suffering" 76 77 WarningC2PAViolence = "cwarn:violence" 77 78 ) 79 + 80 + const BlueskyProfileGoliveKey = "place.stream.live.golive"
+9 -4
pkg/director/director.go
··· 7 7 8 8 "github.com/streamplace/oatproxy/pkg/oatproxy" 9 9 "golang.org/x/sync/errgroup" 10 + "stream.place/streamplace/pkg/atproto" 10 11 "stream.place/streamplace/pkg/bus" 11 12 "stream.place/streamplace/pkg/config" 12 13 "stream.place/streamplace/pkg/localdb" ··· 34 35 statefulDB *statedb.StatefulDB 35 36 replicator replication.Replicator 36 37 localDB localdb.LocalDB 38 + atsync *atproto.ATProtoSynchronizer 37 39 } 38 40 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 { 41 + func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator, ldb localdb.LocalDB, atsync *atproto.ATProtoSynchronizer) *Director { 40 42 return &Director{ 41 43 mm: mm, 42 44 mod: mod, ··· 48 50 statefulDB: statefulDB, 49 51 replicator: replicator, 50 52 localDB: ldb, 53 + atsync: atsync, 51 54 } 52 55 } 53 56 ··· 80 83 statefulDB: d.statefulDB, 81 84 replicator: d.replicator, 82 85 // 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 + statusUpdateChan: make(chan struct{}, 1), 87 + originUpdateChan: make(chan struct{}, 1), 88 + livestreamUpdateChan: make(chan struct{}, 1), 89 + localDB: d.localDB, 90 + atsync: d.atsync, 86 91 } 87 92 d.streamSessions[not.Segment.RepoDID] = ss 88 93 g.Go(func() error {
+120 -24
pkg/director/stream_session.go
··· 9 9 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 11 "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 12 13 lexutil "github.com/bluesky-social/indigo/lex/util" 13 14 "github.com/bluesky-social/indigo/util" 14 15 "github.com/bluesky-social/indigo/xrpc" ··· 17 18 "golang.org/x/sync/errgroup" 18 19 "stream.place/streamplace/pkg/aqhttp" 19 20 "stream.place/streamplace/pkg/aqtime" 21 + "stream.place/streamplace/pkg/atproto" 20 22 "stream.place/streamplace/pkg/bus" 21 23 "stream.place/streamplace/pkg/config" 22 24 "stream.place/streamplace/pkg/livepeer" ··· 48 50 localDB localdb.LocalDB 49 51 50 52 // Channels for background workers 51 - statusUpdateChan chan struct{} // Signal to update status 52 - originUpdateChan chan struct{} // Signal to update broadcast origin 53 + statusUpdateChan chan struct{} // Signal to update status 54 + originUpdateChan chan struct{} // Signal to update broadcast origin 55 + livestreamUpdateChan chan struct{} // Signal to update livestream 53 56 54 57 g *errgroup.Group 55 58 started chan struct{} ··· 57 60 packets []bus.PacketizedSegment 58 61 statefulDB *statedb.StatefulDB 59 62 replicator replication.Replicator 63 + atsync *atproto.ATProtoSynchronizer 64 + 65 + lastLivestreamTime time.Time 60 66 } 61 67 62 68 func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotification) error { ··· 97 103 allRenditions = append([]renditions.Rendition{sourceRendition}, allRenditions...) 98 104 ss.hls = media.NewM3U8(allRenditions) 99 105 100 - // for _, r := range allRenditions { 101 - // g.Go(func() error { 102 - // for { 103 - // if ctx.Err() != nil { 104 - // return nil 105 - // } 106 - // err := ss.mm.ToHLS(ctx, spseg.Creator, r.Name, ss.hls) 107 - // if ctx.Err() != nil { 108 - // return nil 109 - // } 110 - // log.Warn(ctx, "hls failed, retrying in 5 seconds", "error", err) 111 - // time.Sleep(time.Second * 5) 112 - // } 113 - // }) 114 - // } 115 - 116 106 close(ss.started) 117 107 118 - // Start background workers for status and origin updates 108 + // Start background workers for status, origin, and livestream updates 119 109 ss.g.Go(func() error { 120 110 return ss.statusUpdateLoop(ctx, spseg.Creator) 121 111 }) 122 112 ss.g.Go(func() error { 123 113 return ss.originUpdateLoop(ctx) 114 + }) 115 + ss.g.Go(func() error { 116 + return ss.livestreamUpdateLoop(ctx, spseg.Creator) 124 117 }) 125 118 126 119 if notif.Local { ··· 192 185 ss.bus.Publish(spseg.Creator, spseg) 193 186 ss.Go(ctx, func() error { 194 187 return ss.AddPlaybackSegment(ctx, spseg, "source", &bus.Seg{ 195 - Filepath: notif.Segment.ID, 196 - Data: notif.Data, 188 + Filepath: notif.Segment.ID, 189 + Data: notif.Data, 190 + Published: notif.Metadata.Published, 197 191 }) 198 192 }) 199 193 194 + ss.Go(ctx, func() error { 195 + return ss.statefulDB.UpsertBroadcastOrigin(spseg.Creator, ss.cli.BroadcasterDID(), time.Now()) 196 + }) 197 + 200 198 if ss.cli.Thumbnail { 201 199 ss.Go(ctx, func() error { 202 200 return ss.Thumbnail(ctx, spseg.Creator, notif) 203 201 }) 204 202 } 205 203 204 + // everything else is for published segments 205 + if !notif.Metadata.Published { 206 + return nil 207 + } 208 + 206 209 if notif.Local { 207 210 ss.UpdateStatus(ctx, spseg.Creator) 208 211 ss.UpdateBroadcastOrigin(ctx) 212 + ss.UpdateLivestream(ctx, spseg.Creator) 209 213 } 210 214 211 215 if ss.cli.LivepeerGatewayURL != "" { ··· 365 369 if err != nil { 366 370 return fmt.Errorf("could not get latest livestream for repoDID: %w", err) 367 371 } 372 + if ls == nil { 373 + log.Debug(ctx, "no livestream found, skipping status update", "repoDID", repoDID) 374 + return nil 375 + } 368 376 lsv, err := ls.ToLivestreamView() 369 377 if err != nil { 370 378 return fmt.Errorf("could not convert livestream to streamplace livestream: %w", err) ··· 454 462 return nil 455 463 } 456 464 465 + var livestreamUpdateInterval = time.Second * 30 466 + 467 + // UpdateLivestream signals the background worker to update the livestream record (non-blocking) 468 + func (ss *StreamSession) UpdateLivestream(ctx context.Context, repoDID string) { 469 + select { 470 + case ss.livestreamUpdateChan <- struct{}{}: 471 + log.Warn(ctx, "livestream update signal sent") 472 + default: 473 + log.Warn(ctx, "livestream update channel full, signal already pending") 474 + // Channel full, signal already pending 475 + } 476 + } 477 + 478 + // livestreamUpdateLoop runs as a background goroutine for the session lifetime 479 + func (ss *StreamSession) livestreamUpdateLoop(ctx context.Context, repoDID string) error { 480 + ctx = log.WithLogValues(ctx, "func", "livestreamUpdateLoop") 481 + for { 482 + select { 483 + case <-ctx.Done(): 484 + return nil 485 + case <-ss.livestreamUpdateChan: 486 + if time.Since(ss.lastLivestreamTime) < livestreamUpdateInterval { 487 + log.Warn(ctx, "not updating livestream, last livestream was less than 30 seconds ago") 488 + continue 489 + } 490 + if err := ss.doUpdateLivestream(ctx, repoDID); err != nil { 491 + log.Error(ctx, "failed to update livestream", "error", err) 492 + } 493 + } 494 + } 495 + } 496 + 497 + // doUpdateLivestream performs the actual livestream record update work 498 + func (ss *StreamSession) doUpdateLivestream(ctx context.Context, repoDID string) error { 499 + ctx = log.WithLogValues(ctx, "func", "doUpdateLivestream") 500 + 501 + lastLivestream, err := ss.mod.GetLatestLivestreamForRepo(repoDID) 502 + if err != nil { 503 + return fmt.Errorf("could not get latest livestream for repoDID: %w", err) 504 + } 505 + if lastLivestream == nil { 506 + log.Debug(ctx, "no livestream found, skipping livestream update") 507 + return nil 508 + } 509 + lsv, err := lastLivestream.ToLivestreamView() 510 + if err != nil { 511 + return fmt.Errorf("could not convert livestream to streamplace livestream: %w", err) 512 + } 513 + lsvr, ok := lsv.Record.Val.(*streamplace.Livestream) 514 + if !ok { 515 + return fmt.Errorf("livestream is not a streamplace livestream") 516 + } 517 + 518 + aturi, err := syntax.ParseATURI(lastLivestream.URI) 519 + if err != nil { 520 + return fmt.Errorf("could not parse livestream URI: %w", err) 521 + } 522 + 523 + now := time.Now().UTC().Format(util.ISO8601) 524 + lsvr.LastSeenAt = &now 525 + 526 + inp := comatproto.RepoPutRecord_Input{ 527 + Collection: "place.stream.livestream", 528 + Record: &lexutil.LexiconTypeDecoder{Val: lsvr}, 529 + Rkey: aturi.RecordKey().String(), 530 + Repo: ss.repoDID, 531 + SwapRecord: &lastLivestream.CID, 532 + } 533 + out := comatproto.RepoPutRecord_Output{} 534 + 535 + client, err := ss.GetClientByDID(ss.repoDID) 536 + if err != nil { 537 + return fmt.Errorf("could not get xrpc client for repoDID: %w", err) 538 + } 539 + 540 + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &out) 541 + if err != nil { 542 + return fmt.Errorf("could not update livestream record: %w", err) 543 + } 544 + 545 + log.Warn(ctx, "updated livestream record", "uri", lastLivestream.URI) 546 + ss.lastLivestreamTime = time.Now() 547 + 548 + return nil 549 + } 550 + 457 551 func (ss *StreamSession) DeleteStatus(repoDID string) error { 458 552 // need a special extra context because the stream session context is already cancelled 459 553 // No lock needed - this runs during teardown after the background worker has exited ··· 629 723 } 630 724 631 725 func (ss *StreamSession) AddPlaybackSegment(ctx context.Context, spseg *streamplace.Segment, rendition string, seg *bus.Seg) error { 632 - ss.Go(ctx, func() error { 633 - return ss.AddToHLS(ctx, spseg, rendition, seg.Data) 634 - }) 726 + if seg.Published { 727 + ss.Go(ctx, func() error { 728 + return ss.AddToHLS(ctx, spseg, rendition, seg.Data) 729 + }) 730 + } 635 731 ss.Go(ctx, func() error { 636 732 return ss.AddToWebRTC(ctx, spseg, rendition, seg) 637 733 })
+1 -1
pkg/localdb/localdb.go
··· 17 17 CreateSegment(segment *Segment) error 18 18 MostRecentSegments() ([]Segment, error) 19 19 LatestSegmentForUser(user string) (*Segment, error) 20 - LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 20 + LatestSegmentsForUser(user string, limit int, includeUnpublished bool, before *time.Time, after *time.Time) ([]Segment, error) 21 21 FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 22 22 CreateThumbnail(thumb *Thumbnail) error 23 23 LatestThumbnailForUser(user string) (*Thumbnail, error)
+12 -7
pkg/localdb/segment.go
··· 138 138 type Segment struct { 139 139 ID string `json:"id" gorm:"primaryKey"` 140 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"` 141 + StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time;index:latest_segments_published,priority:2"` 142 + RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did;index:latest_segments_published,priority:1"` 143 143 Title string `json:"title"` 144 144 Size int `json:"size" gorm:"column:size"` 145 145 MediaData *SegmentMediaData `json:"mediaData,omitempty"` ··· 147 147 ContentRights *ContentRights `json:"contentRights,omitempty"` 148 148 DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 149 149 DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 150 + Published bool `json:"published" gorm:"column:published;index:latest_segments_published,priority:3"` 150 151 } 151 152 152 153 func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { ··· 238 239 239 240 err := m.DB.Table("segments"). 240 241 Select("segments.*"). 241 - Where("start_time > ?", thirtySecondsAgo.UTC()). 242 + Where("start_time > ? AND published = ?", thirtySecondsAgo.UTC(), true). 242 243 Order("start_time DESC"). 243 244 Find(&segments).Error 244 245 if err != nil { ··· 270 271 271 272 func (m *LocalDatabase) LatestSegmentForUser(user string) (*Segment, error) { 272 273 var seg Segment 273 - err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 274 + err := m.DB.Model(Segment{}).Where("repo_did = ? AND published = ?", user, true).Order("start_time DESC").First(&seg).Error 274 275 if err != nil { 275 276 return nil, err 276 277 } ··· 288 289 289 290 err := m.DB.Table("segments"). 290 291 Select("DISTINCT repo_did"). 291 - Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 292 + Where("repo_did IN ? AND start_time > ? AND published = ?", repoDIDs, thirtySecondsAgo.UTC(), true). 292 293 Pluck("repo_did", &liveDIDs).Error 293 294 294 295 if err != nil { ··· 298 299 return liveDIDs, nil 299 300 } 300 301 301 - func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 302 + func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, includeUnpublished bool, before *time.Time, after *time.Time) ([]Segment, error) { 302 303 var segs []Segment 303 304 if before == nil { 304 305 later := time.Now().Add(1000 * time.Hour) ··· 308 309 earlier := time.Time{} 309 310 after = &earlier 310 311 } 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 + query := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()) 313 + if !includeUnpublished { 314 + query = query.Where("published = ?", true) 315 + } 316 + err := query.Order("start_time DESC").Limit(limit).Find(&segs).Error 312 317 if err != nil { 313 318 return nil, err 314 319 }
+1 -1
pkg/media/clip_user.go
··· 14 14 ) 15 15 16 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) 17 + segments, err := localDB.LatestSegmentsForUser(user, -1, false, before, after) 18 18 if err != nil { 19 19 return fmt.Errorf("unable to get segments: %w", err) 20 20 }
+67 -39
pkg/media/manifest_builder.go
··· 6 6 "fmt" 7 7 8 8 "stream.place/streamplace/pkg/aqtime" 9 + "stream.place/streamplace/pkg/config" 9 10 "stream.place/streamplace/pkg/constants" 10 11 "stream.place/streamplace/pkg/log" 11 12 "stream.place/streamplace/pkg/model" ··· 24 25 // See https://iptc.org/std/videometadatahub/recommendation/IPTC-VideoMetadataHub-props-Rec_1.6.html 25 26 type ManifestBuilder struct { 26 27 model model.Model 28 + cli *config.CLI 27 29 } 28 30 29 - func NewManifestBuilder(model model.Model) *ManifestBuilder { 31 + func NewManifestBuilder(model model.Model, cli *config.CLI) *ManifestBuilder { 30 32 return &ManifestBuilder{ 31 33 model: model, 34 + cli: cli, 32 35 } 33 36 } 34 37 ··· 43 46 return nil, fmt.Errorf("failed to unmarshal record: %w", err) 44 47 } 45 48 return o, nil 49 + } 50 + 51 + func (mb *ManifestBuilder) getLivestream(ctx context.Context, streamerName string) (*streamplace.Livestream, error) { 52 + if mb.model == nil { 53 + return nil, fmt.Errorf("model is nil") 54 + } 55 + livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName) 56 + if err != nil { 57 + return nil, fmt.Errorf("failed to retrieve livestream: %w", err) 58 + } 59 + if livestream == nil { 60 + return nil, nil 61 + } 62 + livestreamRecord, err := livestream.ToLivestreamView() 63 + if err != nil { 64 + return nil, fmt.Errorf("failed to convert livestream to view: %w", err) 65 + } 66 + ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream) 67 + if !ok { 68 + return nil, fmt.Errorf("livestream is not a streamplace livestream") 69 + } 70 + return ls, nil 46 71 } 47 72 48 73 func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName string, start int64) ([]byte, error) { 49 74 log.Debug(ctx, "🔍 BuildManifest ENTRY", "streamer", streamerName, "start", start) 75 + 76 + shouldPublish := false 77 + 78 + // Add livestream title if available 79 + livestreamTitle := "unpublished livestream" // default fallback 80 + ls, err := mb.getLivestream(ctx, streamerName) 81 + if err != nil { 82 + return nil, fmt.Errorf("failed to get livestream: %w", err) 83 + } 84 + if ls != nil { 85 + livestreamTitle = ls.Title 86 + shouldPublish = ls.EndedAt == nil 87 + } 88 + // for testing only 89 + if mb.cli.WideOpen { 90 + shouldPublish = true 91 + } 92 + 50 93 // Start with base manifest 51 94 startTime := aqtime.FromMillis(start).String() 95 + actions := []obj{ 96 + { 97 + "action": "c2pa.created", 98 + "when": startTime, 99 + }, 100 + } 101 + if shouldPublish { 102 + actions = append(actions, obj{ 103 + "action": "c2pa.published", 104 + "when": startTime, 105 + }) 106 + } 52 107 mani := obj{ 53 108 "title": fmt.Sprintf("Livestream Segment at %s", startTime), 54 109 "assertions": []obj{ ··· 56 111 { 57 112 "label": "c2pa.actions", 58 113 "data": obj{ 59 - "actions": []obj{ 60 - { 61 - "action": "c2pa.created", 62 - "when": startTime, 63 - }, 64 - { 65 - "action": "c2pa.published", 66 - "when": startTime, 67 - }, 68 - }, 114 + "actions": actions, 69 115 }, 70 116 }, 71 117 // Content metadata, with extra custom fields added later ··· 110 156 }) 111 157 } 112 158 } else { 113 - log.Warn(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName) 114 - } 115 - } 116 - 117 - // Add livestream title if available 118 - livestreamTitle := "livestream" // default fallback 119 - if mb.model != nil { 120 - livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName) 121 - if err != nil { 122 - log.Warn(ctx, "ManifestBuilder: failed to retrieve livestream, using default title", "error", err, "did", streamerName) 123 - } else if livestream != nil { 124 - // Extract title from livestream record 125 - livestreamRecord, err := livestream.ToLivestreamView() 126 - if err != nil { 127 - log.Warn(ctx, "ManifestBuilder: failed to convert livestream to view, using default title", "error", err, "did", streamerName) 128 - } else { 129 - if ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream); ok { 130 - livestreamTitle = ls.Title 131 - livestreamObj, err := toObj(ls) 132 - if err != nil { 133 - return nil, fmt.Errorf("failed to marshal livestream: %w", err) 134 - } 135 - mani["assertions"] = append(mani["assertions"].([]obj), obj{ 136 - "label": "place.stream.livestream", 137 - "data": livestreamObj, 138 - }) 139 - } 140 - } 159 + log.Debug(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName) 141 160 } 142 161 } 143 162 144 163 // Update the manifest title with the retrieved livestream title 145 164 mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = livestreamTitle 165 + 166 + if ls != nil { 167 + mani["assertions"] = append(mani["assertions"].([]obj), obj{ 168 + "label": "place.stream.livestream", 169 + "data": ls, 170 + }) 171 + } else { 172 + log.Warn(ctx, "ManifestBuilder: no livestream found for streamer", "did", streamerName) 173 + } 146 174 147 175 // Convert manifest to JSON bytes for use with Rust c2pa library 148 176 manifestBs, err := json.Marshal(mani)
+31 -2
pkg/media/media.go
··· 197 197 DistributionPolicy *localdb.DistributionPolicy 198 198 MetadataConfiguration *streamplace.MetadataConfiguration 199 199 Livestream *streamplace.Livestream 200 + Published bool 200 201 } 201 202 202 203 var ErrMissingMetadata = errors.New("missing segment metadata") 203 204 var ErrInvalidMetadata = errors.New("invalid segment metadata") 205 + var C2PAActionsV2Label = "c2pa.actions.v2" 206 + var C2PAPublishedAction = "c2pa.published" 204 207 205 208 func ParseSegmentAssertions(ctx context.Context, mani *c2patypes.Manifest) (*SegmentMetadata, error) { 206 209 _, span := otel.Tracer("signer").Start(ctx, "ParseSegmentAssertions") 207 210 defer span.End() 208 211 var ass *c2patypes.ManifestAssertion 212 + isPublished := false 209 213 for _, a := range mani.Assertions { 210 214 if a.Label == StreamplaceMetadata { 211 215 ass = &a 212 - break 216 + continue 213 217 } 214 218 if a.Label == "place.stream.metadata" { 215 219 // backwards compatibility for old manifests 216 220 ass = &a 217 - break 221 + continue 222 + } 223 + if a.Label == C2PAActionsV2Label { 224 + data, ok := a.Data.(map[string]any) 225 + if !ok { 226 + return nil, ErrInvalidMetadata 227 + } 228 + actions, ok := data["actions"].([]any) 229 + if !ok { 230 + return nil, ErrInvalidMetadata 231 + } 232 + for _, action := range actions { 233 + actionMap, ok := action.(map[string]any) 234 + if !ok { 235 + return nil, ErrInvalidMetadata 236 + } 237 + actionType, ok := actionMap["action"].(string) 238 + if !ok { 239 + return nil, ErrInvalidMetadata 240 + } 241 + if actionType == C2PAPublishedAction { 242 + isPublished = true 243 + break 244 + } 245 + } 218 246 } 219 247 } 220 248 if ass == nil { ··· 268 296 DistributionPolicy: distributionPolicy, 269 297 MetadataConfiguration: metadataConfiguration, 270 298 Livestream: livestream, 299 + Published: isPublished, 271 300 } 272 301 return &out, nil 273 302 }
+1 -1
pkg/media/media_signer.go
··· 78 78 TAURL: cli.TAURL, 79 79 AQPub: pub, 80 80 did: did.DIDKey(), 81 - manifestBuilder: NewManifestBuilder(model), 81 + manifestBuilder: NewManifestBuilder(model, cli), 82 82 }, nil 83 83 } 84 84
+2 -1
pkg/media/validate.go
··· 75 75 signingKeyDID = meta.Creator 76 76 repoDID = meta.Creator 77 77 } else { 78 - repo, err := mm.atsync.SyncBlueskyRepoCached(ctx, meta.Creator, mm.model) 78 + repo, err := mm.atsync.SyncBlueskyRepoCached(ctx, meta.Creator) 79 79 if err != nil { 80 80 return err 81 81 } ··· 129 129 ContentRights: meta.ContentRights, 130 130 DistributionPolicy: meta.DistributionPolicy, 131 131 DeleteAfter: deleteAfter, 132 + Published: meta.Published, 132 133 } 133 134 mm.newSegmentSubsMutex.RLock() 134 135 defer mm.newSegmentSubsMutex.RUnlock()
+5 -1
pkg/media/webrtc_playback2.go
··· 14 14 ) 15 15 16 16 // This function remains in scope for the duration of a single users' playback 17 - func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) { 17 + func (mm *MediaManager) WebRTCPlayback2(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription, viewer string) (*webrtc.SessionDescription, error) { 18 18 uu, err := uuid.NewV7() 19 19 if err != nil { 20 20 return nil, err ··· 93 93 return 94 94 case file := <-segChan.C: 95 95 log.Debug(ctx, "got segment", "file", file.Filepath) 96 + if !file.Published && viewer != user && !mm.cli.WideOpen { 97 + log.Warn(ctx, "segment is not published and viewer is not the user", "viewer", viewer, "user", user) 98 + continue 99 + } 96 100 latency += file.PacketizedData.Duration 97 101 packetQueue <- file.PacketizedData 98 102 }
+1 -1
pkg/media/webrtc_playback2_test.go
··· 17 17 Type: webrtc.SDPTypeOffer, 18 18 SDP: firefoxNoH264SDP, 19 19 } 20 - answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer) 20 + answer, err := mm.WebRTCPlayback2(context.Background(), "test-user", "test-rendition", offer, "") 21 21 require.ErrorContains(t, err, "RTPSender created with no codecs") 22 22 require.Nil(t, answer) 23 23 }
+69
pkg/model/bsky_profile.go
··· 1 + package model 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + 8 + "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "gorm.io/gorm" 12 + "gorm.io/gorm/clause" 13 + "stream.place/streamplace/pkg/spid" 14 + ) 15 + 16 + type BskyProfile struct { 17 + URI string `json:"uri" gorm:"primaryKey;column:uri"` 18 + CID string `json:"cid" gorm:"column:cid"` 19 + RepoDID string `json:"repoDID" gorm:"column:repo_did"` 20 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 21 + Record *[]byte `json:"record" gorm:"column:record"` 22 + WasStreamplace bool `json:"wasStreamplace" gorm:"primaryKey;column:was_streamplace"` 23 + } 24 + 25 + func (m *DBModel) UpsertBskyProfile(ctx context.Context, aturi syntax.ATURI, recBs []byte, wasStreamplace bool) error { 26 + cid, err := spid.GetCIDFromBytes(recBs) 27 + if err != nil { 28 + return fmt.Errorf("failed to get cid: %w", err) 29 + } 30 + dbProfile := &BskyProfile{ 31 + URI: aturi.String(), 32 + CID: cid.String(), 33 + RepoDID: aturi.Authority().String(), 34 + Record: &recBs, 35 + WasStreamplace: wasStreamplace, 36 + } 37 + 38 + // Use GORM's OnConflict to handle unique/primary conflicts 39 + // If a conflict (same PK), then update the relevant fields 40 + return m.DB. 41 + Clauses( 42 + // Conflict columns: uri, was_streamplace (matching primaryKey in struct) 43 + clause.OnConflict{ 44 + Columns: []clause.Column{{Name: "uri"}, {Name: "was_streamplace"}}, 45 + DoUpdates: clause.AssignmentColumns([]string{"cid", "repo_did", "record"}), 46 + }, 47 + ). 48 + Create(dbProfile).Error 49 + } 50 + 51 + func (m *DBModel) GetBskyProfile(ctx context.Context, did string, wasStreamplace bool) (*bsky.ActorProfile, error) { 52 + var profile BskyProfile 53 + err := m.DB.Where("uri = ? AND was_streamplace = ?", fmt.Sprintf("at://%s/app.bsky.actor.profile/self", did), wasStreamplace).First(&profile).Error 54 + if errors.Is(err, gorm.ErrRecordNotFound) { 55 + return nil, nil 56 + } 57 + if err != nil { 58 + return nil, err 59 + } 60 + rec, err := lexutil.CborDecodeValue(*profile.Record) 61 + if err != nil { 62 + return nil, fmt.Errorf("failed to decode profile record: %w", err) 63 + } 64 + bskyProfile, ok := rec.(*bsky.ActorProfile) 65 + if !ok { 66 + return nil, fmt.Errorf("invalid profile record") 67 + } 68 + return bskyProfile, nil 69 + }
+19
pkg/model/livestream.go
··· 52 52 }).Create(ls).Error 53 53 } 54 54 55 + func (m *DBModel) GetLivestream(uri string) (*Livestream, error) { 56 + var livestream Livestream 57 + err := m.DB. 58 + Preload("Repo"). 59 + Preload("Post"). 60 + Where("uri = ?", uri). 61 + First(&livestream).Error 62 + if errors.Is(err, gorm.ErrRecordNotFound) { 63 + return nil, nil 64 + } 65 + if err != nil { 66 + return nil, fmt.Errorf("error retrieving livestream by uri: %w", err) 67 + } 68 + return &livestream, nil 69 + } 70 + 55 71 // GetLatestLivestreamForRepo returns the most recent livestream for a given repo DID 56 72 func (m *DBModel) GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) { 57 73 var livestream Livestream ··· 61 77 Where("repo_did = ?", repoDID). 62 78 Order("created_at DESC"). 63 79 First(&livestream).Error 80 + if errors.Is(err, gorm.ErrRecordNotFound) { 81 + return nil, nil 82 + } 64 83 if err != nil { 65 84 return nil, fmt.Errorf("error retrieving latest livestream: %w", err) 66 85 }
+5
pkg/model/model.go
··· 57 57 GetReplies(repoDID string) ([]*bsky.FeedDefs_PostView, error) 58 58 59 59 CreateLivestream(ctx context.Context, ls *Livestream) error 60 + GetLivestream(uri string) (*Livestream, error) 60 61 GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) 61 62 GetLivestreamByPostURI(postURI string) (*Livestream, error) 62 63 GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) ··· 114 115 115 116 GetRecommendation(userDID string) (*Recommendation, error) 116 117 UpsertRecommendation(rec *Recommendation) error 118 + 119 + UpsertBskyProfile(ctx context.Context, aturi syntax.ATURI, profileBs []byte, wasStreamplace bool) error 120 + GetBskyProfile(ctx context.Context, did string, wasStreamplace bool) (*bsky.ActorProfile, error) 117 121 } 118 122 119 123 var DBRevision = 2 ··· 183 187 Teleport{}, 184 188 ModerationDelegation{}, 185 189 Recommendation{}, 190 + BskyProfile{}, 186 191 } { 187 192 err = db.AutoMigrate(model) 188 193 if err != nil {
+1 -1
pkg/replication/websocketrep/websocket_replicator.go
··· 123 123 } 124 124 conn, _, err := websocket.DefaultDialer.Dial(*origin.WebsocketURL, nil) 125 125 if err != nil { 126 - return fmt.Errorf("could not dial websocket: %w", err) 126 + return fmt.Errorf("could not dial websocket (%s): %w", *origin.WebsocketURL, err) 127 127 } 128 128 defer conn.Close() 129 129 for {
+6 -2
pkg/spid/cid.go
··· 9 9 ) 10 10 11 11 func GetCID(record repo.CborMarshaler) (*cid.Cid, error) { 12 - builder := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256) 13 12 buf := bytes.NewBuffer(nil) 14 13 err := record.MarshalCBOR(buf) 15 14 if err != nil { 16 15 return nil, err 17 16 } 18 - c, err := builder.Sum(buf.Bytes()) 17 + return GetCIDFromBytes(buf.Bytes()) 18 + } 19 + 20 + func GetCIDFromBytes(bs []byte) (*cid.Cid, error) { 21 + builder := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256) 22 + c, err := builder.Sum(bs) 19 23 if err != nil { 20 24 return nil, err 21 25 }
+4
pkg/spxrpc/app_bsky_feed.go
··· 66 66 log.Error(ctx, "failed to get latest livestream, skipping", "repoDID", seg.RepoDID, "error", err) 67 67 continue 68 68 } 69 + if ls == nil { 70 + log.Error(ctx, "no livestream found, skipping", "repoDID", seg.RepoDID) 71 + continue 72 + } 69 73 if ls.PostURI != "" { 70 74 posts = append(posts, model.FeedPost{ 71 75 URI: ls.PostURI,
+2 -2
pkg/spxrpc/com_atproto_repo.go
··· 92 92 } 93 93 94 94 return &comatproto.RepoDescribeRepo_Output{ 95 - Handle: s.cli.MyDID(), 96 - Did: s.cli.MyDID(), 95 + Handle: s.cli.BroadcasterDID(), 96 + Did: s.cli.BroadcasterDID(), 97 97 DidDoc: atproto.DIDDoc(s.cli.BroadcasterHost), 98 98 Collections: []string{ 99 99 "com.atproto.lexicon.schema",
+1 -37
pkg/spxrpc/og.go
··· 20 20 21 21 "golang.org/x/image/draw" 22 22 23 - "github.com/bluesky-social/indigo/api/bsky" 24 - "github.com/bluesky-social/indigo/xrpc" 25 23 "github.com/labstack/echo/v4" 26 24 "github.com/patrickmn/go-cache" 27 25 "github.com/tdewolff/canvas" ··· 89 87 maxDescriptionLength = 120 90 88 descriptionTruncate = 117 91 89 ) 92 - 93 - var ErrUserNotFound = errors.New("user not found") 94 90 95 91 // blendWithBackground creates a pseudo-transparent color by blending the given color with the background 96 92 // alpha should be between 0.0 (fully background) and 1.0 (fully foreground color) ··· 235 231 handle = username 236 232 description = "Live streaming platform for creators and their communities." 237 233 238 - profileData, err := s.fetchUserProfile(ctx, username) 234 + profileData, err := s.ATSync.FetchUserProfile(ctx, username) 239 235 if err != nil { 240 236 return nil, fmt.Errorf("failed to fetch profile, because %w", err) 241 237 } else if profileData != nil { ··· 513 509 514 510 return data, nil 515 511 } 516 - 517 - func (s *Server) fetchUserProfile(ctx context.Context, username string) (*bsky.ActorDefs_ProfileViewDetailed, error) { 518 - // Use ATSync to resolve username to DID, then fetch full profile from Bluesky 519 - var actor string 520 - 521 - // First try to resolve via internal DB 522 - repo, err := s.ATSync.Model.GetRepoByHandleOrDID(username) 523 - if err != nil { 524 - return nil, fmt.Errorf("%w: %w", ErrUserNotFound, err) 525 - } else if repo != nil { 526 - // Use the DID as it's the most reliable identifier 527 - actor = repo.DID 528 - } else { 529 - return nil, fmt.Errorf("no repo found for username: %s (%w)", username, ErrUserNotFound) 530 - } 531 - 532 - // Fetch full profile from Bluesky public API 533 - client := &xrpc.Client{ 534 - Host: "https://public.api.bsky.app", 535 - } 536 - 537 - profile, err := bsky.ActorGetProfile(ctx, client, actor) 538 - if err != nil { 539 - return nil, fmt.Errorf("failed to fetch profile from Bluesky for '%s': %w", actor, err) 540 - } 541 - 542 - if profile == nil { 543 - return nil, fmt.Errorf("received nil profile from Bluesky API for '%s'", actor) 544 - } 545 - 546 - return profile, nil 547 - }
+329 -31
pkg/spxrpc/place_stream_live.go
··· 1 1 package spxrpc 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 6 + "encoding/json" 5 7 "fmt" 6 8 "net/http" 9 + "net/url" 10 + "os" 11 + "strconv" 7 12 "time" 8 13 9 - "github.com/bluesky-social/indigo/lex/util" 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + bsky "github.com/bluesky-social/indigo/api/bsky" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + lexutil "github.com/bluesky-social/indigo/lex/util" 18 + "github.com/bluesky-social/indigo/util" 19 + "github.com/bluesky-social/indigo/xrpc" 10 20 "github.com/gorilla/websocket" 11 21 "github.com/labstack/echo/v4" 12 22 "github.com/streamplace/oatproxy/pkg/oatproxy" 23 + "stream.place/streamplace/pkg/aqtime" 13 24 "stream.place/streamplace/pkg/log" 14 25 "stream.place/streamplace/pkg/spid" 15 26 "stream.place/streamplace/pkg/spmetrics" 16 27 17 - placestreamtypes "stream.place/streamplace/pkg/streamplace" 28 + placestream "stream.place/streamplace/pkg/streamplace" 18 29 ) 19 30 20 - func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *placestreamtypes.LiveDenyTeleport_Input) (*placestreamtypes.LiveDenyTeleport_Output, error) { 31 + func (s *Server) handlePlaceStreamLiveDenyTeleport(ctx context.Context, input *placestream.LiveDenyTeleport_Input) (*placestream.LiveDenyTeleport_Output, error) { 21 32 session, _ := oatproxy.GetOAuthSession(ctx) 22 33 if session == nil { 23 34 return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") ··· 47 58 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to deny teleport") 48 59 } 49 60 50 - cancelMsg := &placestreamtypes.Livestream_TeleportCanceled{ 61 + cancelMsg := &placestream.Livestream_TeleportCanceled{ 51 62 LexiconTypeID: "place.stream.livestream#teleportCanceled", 52 63 TeleportUri: input.Uri, 53 64 Reason: "denied", ··· 56 67 s.bus.Publish(teleport.RepoDID, cancelMsg) 57 68 s.bus.Publish(teleport.TargetDID, cancelMsg) 58 69 59 - return &placestreamtypes.LiveDenyTeleport_Output{ 70 + return &placestream.LiveDenyTeleport_Output{ 60 71 Success: true, 61 72 }, nil 62 73 } ··· 69 80 }, 70 81 } 71 82 72 - func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before string, limit int, userDID string) (*placestreamtypes.LiveGetSegments_Output, error) { 83 + func (s *Server) handlePlaceStreamLiveGetSegments(ctx context.Context, before string, limit int, userDID string) (*placestream.LiveGetSegments_Output, error) { 73 84 if userDID == "" { 74 85 return nil, echo.NewHTTPError(http.StatusBadRequest, "User DID is required") 75 86 } ··· 82 93 beforeTime = &parsedTime 83 94 } 84 95 85 - segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 96 + includeUnpublished := false 97 + sess, _ := oatproxy.GetOAuthSession(ctx) 98 + if sess != nil && sess.DID == userDID { 99 + includeUnpublished = true 100 + // this user gets sent right to the origin in case we're unpublished 101 + origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(sess.DID) 102 + if err != nil { 103 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) 104 + } 105 + myServerDID := s.cli.ServerDID() 106 + if origin != nil && origin.ServerDID != myServerDID { 107 + data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "GET", "place.stream.live.getSegments", 108 + url.Values{"userDID": {userDID}, "limit": {strconv.Itoa(limit)}, "before": {before}}, 109 + nil, "application/json") 110 + if err != nil { 111 + return nil, fmt.Errorf("error proxying to peer: %w", err) 112 + } 113 + var output placestream.LiveGetSegments_Output 114 + err = json.Unmarshal(data, &output) 115 + if err != nil { 116 + return nil, fmt.Errorf("error unmarshalling response: %w", err) 117 + } 118 + return &output, nil 119 + } 120 + } else { 121 + svc := GetServiceAuth(ctx) 122 + if svc != nil { 123 + // this is a signed request from a peer node, allow them to see unpublished streams 124 + includeUnpublished = true 125 + } 126 + } 127 + 128 + segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, includeUnpublished, beforeTime, nil) 86 129 if err != nil { 87 130 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch segments") 88 131 } 89 132 90 133 // Convert segments to the expected output format 91 - output := &placestreamtypes.LiveGetSegments_Output{ 92 - Segments: make([]*placestreamtypes.Segment_SegmentView, len(segments)), 134 + output := &placestream.LiveGetSegments_Output{ 135 + Segments: make([]*placestream.Segment_SegmentView, len(segments)), 93 136 } 94 137 95 138 for i, segment := range segments { ··· 101 144 if err != nil { 102 145 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get CID: %s", err)) 103 146 } 104 - ltd := &util.LexiconTypeDecoder{Val: record} 147 + ltd := &lexutil.LexiconTypeDecoder{Val: record} 105 148 106 - output.Segments[i] = &placestreamtypes.Segment_SegmentView{ 149 + output.Segments[i] = &placestream.Segment_SegmentView{ 107 150 Record: ltd, 108 151 Cid: c.String(), 109 152 } ··· 112 155 return output, nil 113 156 } 114 157 115 - func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before string, limit int) (*placestreamtypes.LiveGetLiveUsers_Output, error) { 158 + func (s *Server) handlePlaceStreamLiveGetLiveUsers(ctx context.Context, before string, limit int) (*placestream.LiveGetLiveUsers_Output, error) { 116 159 // Check cache first 117 160 cacheKey := fmt.Sprintf("live_users_%s_%d", before, limit) 118 161 if cached, found := s.LiveUsersCache.Get(cacheKey); found { 119 - return cached.(*placestreamtypes.LiveGetLiveUsers_Output), nil 162 + return cached.(*placestream.LiveGetLiveUsers_Output), nil 120 163 } 121 164 122 165 var beforeTime *time.Time ··· 140 183 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch livestreams") 141 184 } 142 185 143 - streams := make([]*placestreamtypes.Livestream_LivestreamView, len(ls)) 186 + streams := make([]*placestream.Livestream_LivestreamView, len(ls)) 144 187 145 188 for i, l := range ls { 146 189 stream, err := l.ToLivestreamView() ··· 148 191 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to convert livestream to streamplace livestream: %s", err)) 149 192 } 150 193 viewers := spmetrics.GetViewCount(stream.Author.Did) 151 - stream.ViewerCount = &placestreamtypes.Livestream_ViewerCount{ 194 + stream.ViewerCount = &placestream.Livestream_ViewerCount{ 152 195 LexiconTypeID: "place.stream.livestream#viewerCount", 153 196 Count: int64(viewers), 154 197 } 155 198 streams[i] = stream 156 199 } 157 200 158 - liveUsers := &placestreamtypes.LiveGetLiveUsers_Output{ 201 + liveUsers := &placestream.LiveGetLiveUsers_Output{ 159 202 Streams: streams, 160 203 } 161 204 ··· 190 233 log.Debug(ctx, "exiting segment reader") 191 234 return 192 235 case file := <-segChan.C: 236 + if !file.Published { 237 + continue 238 + } 193 239 log.Debug(ctx, "got segment", "file", file.Filepath) 194 240 err := ws.WriteMessage(websocket.BinaryMessage, file.Data) 195 241 if err != nil { ··· 216 262 } 217 263 } 218 264 219 - func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, userDID string) (*placestreamtypes.LiveGetRecommendations_Output, error) { 265 + func (s *Server) handlePlaceStreamLiveGetRecommendations(ctx context.Context, userDID string) (*placestream.LiveGetRecommendations_Output, error) { 220 266 if userDID == "" { 221 267 return nil, echo.NewHTTPError(http.StatusBadRequest, "userDID is required") 222 268 } ··· 237 283 } 238 284 239 285 if len(liveStreamers) > 0 { 240 - var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem 286 + var recommendations []*placestream.LiveGetRecommendations_Output_Recommendations_Elem 241 287 for _, did := range liveStreamers { 242 - recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ 243 - LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ 288 + recommendations = append(recommendations, &placestream.LiveGetRecommendations_Output_Recommendations_Elem{ 289 + LiveGetRecommendations_LivestreamRecommendation: &placestream.LiveGetRecommendations_LivestreamRecommendation{ 244 290 Did: did, 245 291 Source: "streamer", 246 292 }, 247 293 }) 248 294 } 249 - return &placestreamtypes.LiveGetRecommendations_Output{ 295 + return &placestream.LiveGetRecommendations_Output{ 250 296 Recommendations: recommendations, 251 297 UserDID: &userDID, 252 298 }, nil ··· 270 316 } 271 317 272 318 if len(liveFollows) > 0 { 273 - var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem 319 + var recommendations []*placestream.LiveGetRecommendations_Output_Recommendations_Elem 274 320 for _, did := range liveFollows { 275 - recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ 276 - LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ 321 + recommendations = append(recommendations, &placestream.LiveGetRecommendations_Output_Recommendations_Elem{ 322 + LiveGetRecommendations_LivestreamRecommendation: &placestream.LiveGetRecommendations_LivestreamRecommendation{ 277 323 Did: did, 278 324 Source: "follows", 279 325 }, 280 326 }) 281 327 } 282 - return &placestreamtypes.LiveGetRecommendations_Output{ 328 + return &placestream.LiveGetRecommendations_Output{ 283 329 Recommendations: recommendations, 284 330 UserDID: &userDID, 285 331 }, nil ··· 293 339 if err != nil { 294 340 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") 295 341 } 296 - var recommendations []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem 342 + var recommendations []*placestream.LiveGetRecommendations_Output_Recommendations_Elem 297 343 for _, did := range liveDefaults { 298 - recommendations = append(recommendations, &placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{ 299 - LiveGetRecommendations_LivestreamRecommendation: &placestreamtypes.LiveGetRecommendations_LivestreamRecommendation{ 344 + recommendations = append(recommendations, &placestream.LiveGetRecommendations_Output_Recommendations_Elem{ 345 + LiveGetRecommendations_LivestreamRecommendation: &placestream.LiveGetRecommendations_LivestreamRecommendation{ 300 346 Did: did, 301 347 Source: "host", 302 348 }, 303 349 }) 304 350 } 305 - return &placestreamtypes.LiveGetRecommendations_Output{ 351 + return &placestream.LiveGetRecommendations_Output{ 306 352 Recommendations: recommendations, 307 353 UserDID: &userDID, 308 354 }, nil 309 355 } 310 356 311 357 // No recommendations available 312 - return &placestreamtypes.LiveGetRecommendations_Output{ 313 - Recommendations: []*placestreamtypes.LiveGetRecommendations_Output_Recommendations_Elem{}, 358 + return &placestream.LiveGetRecommendations_Output{ 359 + Recommendations: []*placestream.LiveGetRecommendations_Output_Recommendations_Elem{}, 314 360 UserDID: &userDID, 315 361 }, nil 316 362 } 363 + 364 + func (s *Server) handlePlaceStreamLiveStartLivestream(ctx context.Context, body *placestream.LiveStartLivestream_Input) (*placestream.LiveStartLivestream_Output, error) { 365 + session, _ := oatproxy.GetOAuthSession(ctx) 366 + if session != nil { 367 + if session.DID != body.Streamer { 368 + return nil, echo.NewHTTPError(http.StatusForbidden, "you are not the streamer") 369 + } 370 + } else { 371 + svc := GetServiceAuth(ctx) 372 + if svc != nil { 373 + streamerSession, err := s.statefulDB.GetSessionByDID(body.Streamer) 374 + if err != nil { 375 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting streamer session", err) 376 + } 377 + if streamerSession == nil { 378 + return nil, echo.NewHTTPError(http.StatusNotFound, "streamer session not found") 379 + } 380 + session = streamerSession 381 + } else { 382 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "you are not authorized") 383 + } 384 + } 385 + 386 + // proxy to the origin node if the streamer is broadcasting elsewhere 387 + origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(session.DID) 388 + if err != nil { 389 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) 390 + } 391 + myDID := s.cli.ServerDID() 392 + if origin != nil && origin.ServerDID != myDID { 393 + bs, err := json.Marshal(body) 394 + if err != nil { 395 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error marshalling body", err) 396 + } 397 + data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "POST", "place.stream.live.startLivestream", 398 + url.Values{}, 399 + bytes.NewReader(bs), "application/json") 400 + if err != nil { 401 + return nil, err 402 + } 403 + var output placestream.LiveStartLivestream_Output 404 + err = json.Unmarshal(data, &output) 405 + if err != nil { 406 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error unmarshalling response", err) 407 + } 408 + return &output, nil 409 + } 410 + 411 + _, client := oatproxy.GetOAuthSession(ctx) 412 + if client == nil { 413 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required to start livestream") 414 + } 415 + 416 + livestream := body.Livestream 417 + now := time.Now().UTC().Format(time.RFC3339) 418 + livestream.LexiconTypeID = "place.stream.livestream" 419 + livestream.CreatedAt = now 420 + livestream.LastSeenAt = &now 421 + 422 + if livestream.Thumb == nil { 423 + // Step 1: get latest thumbnail from localDB and upload to user's PDS 424 + var thumb *lexutil.LexBlob 425 + dbThumb, err := s.localDB.LatestThumbnailForUser(session.DID) 426 + if err != nil { 427 + log.Error(ctx, "failed to get latest thumbnail", "err", err) 428 + } 429 + if dbThumb != nil { 430 + aqt := aqtime.FromTime(dbThumb.Segment.StartTime) 431 + fpath, err := s.cli.SegmentFilePath(session.DID, fmt.Sprintf("%s.%s", aqt.String(), dbThumb.Format)) 432 + if err != nil { 433 + log.Error(ctx, "failed to get thumbnail file path", "err", err) 434 + } else { 435 + thumbData, err := os.ReadFile(fpath) 436 + if err != nil { 437 + log.Error(ctx, "failed to read thumbnail file", "err", err) 438 + } else { 439 + mimeType := "image/jpeg" 440 + if dbThumb.Format == "png" { 441 + mimeType = "image/png" 442 + } 443 + 444 + // Step 2: upload to user's PDS 445 + var uploadOut comatproto.RepoUploadBlob_Output 446 + err = client.Do(ctx, xrpc.Procedure, mimeType, "com.atproto.repo.uploadBlob", nil, bytes.NewReader(thumbData), &uploadOut) 447 + if err != nil { 448 + log.Error(ctx, "failed to upload thumbnail to PDS", "err", err) 449 + } else { 450 + thumb = uploadOut.Blob 451 + } 452 + } 453 + } 454 + } 455 + livestream.Thumb = thumb 456 + } 457 + 458 + // Step 3: create a Bluesky post announcing the livestream 459 + repo, err := s.model.GetRepo(session.DID) 460 + if err != nil { 461 + log.Error(ctx, "failed to get repo", "err", err) 462 + } 463 + 464 + handle := session.DID 465 + if repo != nil && repo.Handle != "" { 466 + handle = repo.Handle 467 + } 468 + 469 + canonicalUrl := fmt.Sprintf("https://%s/%s", s.cli.BroadcasterHost, handle) 470 + if livestream.CanonicalUrl != nil && *livestream.CanonicalUrl != "" { 471 + canonicalUrl = *livestream.CanonicalUrl 472 + } 473 + 474 + if body.CreateBlueskyPost == nil || *body.CreateBlueskyPost { 475 + prefix := "🔴 LIVE " 476 + suffix := " " + livestream.Title 477 + postText := prefix + canonicalUrl + suffix 478 + 479 + linkStart := int64(len(prefix)) 480 + linkEnd := linkStart + int64(len(canonicalUrl)) 481 + 482 + postRecord := &bsky.FeedPost{ 483 + LexiconTypeID: "app.bsky.feed.post", 484 + Text: postText, 485 + CreatedAt: now, 486 + Langs: []string{"en"}, 487 + Facets: []*bsky.RichtextFacet{ 488 + { 489 + Index: &bsky.RichtextFacet_ByteSlice{ 490 + ByteStart: linkStart, 491 + ByteEnd: linkEnd, 492 + }, 493 + Features: []*bsky.RichtextFacet_Features_Elem{ 494 + { 495 + RichtextFacet_Link: &bsky.RichtextFacet_Link{ 496 + LexiconTypeID: "app.bsky.richtext.facet#link", 497 + Uri: canonicalUrl, 498 + }, 499 + }, 500 + }, 501 + }, 502 + }, 503 + Embed: &bsky.FeedPost_Embed{ 504 + EmbedExternal: &bsky.EmbedExternal{ 505 + External: &bsky.EmbedExternal_External{ 506 + Title: fmt.Sprintf("@%s is 🔴LIVE on %s!", handle, s.cli.BroadcasterHost), 507 + Uri: canonicalUrl, 508 + Description: livestream.Title, 509 + Thumb: livestream.Thumb, 510 + }, 511 + }, 512 + }, 513 + } 514 + 515 + postInput := comatproto.RepoCreateRecord_Input{ 516 + Collection: "app.bsky.feed.post", 517 + Record: &lexutil.LexiconTypeDecoder{Val: postRecord}, 518 + Repo: session.DID, 519 + } 520 + var postOutput comatproto.RepoCreateRecord_Output 521 + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, postInput, &postOutput) 522 + if err != nil { 523 + log.Error(ctx, "failed to create bluesky post", "err", err) 524 + } else { 525 + livestream.Post = &comatproto.RepoStrongRef{ 526 + Uri: postOutput.Uri, 527 + Cid: postOutput.Cid, 528 + } 529 + } 530 + } 531 + 532 + // Step 4: create the place.stream.livestream record 533 + lsInput := comatproto.RepoCreateRecord_Input{ 534 + Collection: "place.stream.livestream", 535 + Record: &lexutil.LexiconTypeDecoder{Val: livestream}, 536 + Repo: session.DID, 537 + } 538 + var lsOutput comatproto.RepoCreateRecord_Output 539 + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, lsInput, &lsOutput) 540 + if err != nil { 541 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create livestream record: %v", err)) 542 + } 543 + 544 + return &placestream.LiveStartLivestream_Output{ 545 + Uri: lsOutput.Uri, 546 + Cid: lsOutput.Cid, 547 + }, nil 548 + } 549 + 550 + func (s *Server) handlePlaceStreamLiveStopLivestream(ctx context.Context, body *placestream.LiveStopLivestream_Input) (*placestream.LiveStopLivestream_Output, error) { 551 + now := time.Now().UTC().Format(util.ISO8601) 552 + session, _ := oatproxy.GetOAuthSession(ctx) 553 + 554 + _, client := oatproxy.GetOAuthSession(ctx) 555 + if client == nil { 556 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required to stop livestream") 557 + } 558 + 559 + livestream, err := s.model.GetLatestLivestreamForRepo(session.DID) 560 + if err != nil { 561 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting livestream", err) 562 + } 563 + 564 + livestreamView, err := livestream.ToLivestreamView() 565 + if err != nil { 566 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error converting livestream to view", err) 567 + } 568 + 569 + livestreamRecord, ok := livestreamView.Record.Val.(*placestream.Livestream) 570 + if !ok { 571 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "livestream is not a streamplace livestream") 572 + } 573 + 574 + if livestreamRecord.EndedAt != nil { 575 + return nil, echo.NewHTTPError(http.StatusBadRequest, "livestream has already ended") 576 + } 577 + 578 + livestreamRecord.EndedAt = &now 579 + 580 + aturi, err := syntax.ParseATURI(livestreamView.Uri) 581 + if err != nil { 582 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error parsing ATURI", err) 583 + } 584 + 585 + var swapRecord *string 586 + getOutput := comatproto.RepoGetRecord_Output{} 587 + err = client.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ 588 + "repo": session.DID, 589 + "collection": "place.stream.livestream", 590 + "rkey": aturi.RecordKey().String(), 591 + }, nil, &getOutput) 592 + if err != nil { 593 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting livestream record", err) 594 + } 595 + swapRecord = getOutput.Cid 596 + 597 + lsInput := comatproto.RepoPutRecord_Input{ 598 + Collection: "place.stream.livestream", 599 + Record: &lexutil.LexiconTypeDecoder{Val: livestreamRecord}, 600 + Rkey: aturi.RecordKey().String(), 601 + Repo: session.DID, 602 + SwapRecord: swapRecord, 603 + } 604 + var lsOutput comatproto.RepoPutRecord_Output 605 + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, lsInput, &lsOutput) 606 + if err != nil { 607 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error updating livestream record", err) 608 + } 609 + 610 + return &placestream.LiveStopLivestream_Output{ 611 + Uri: lsOutput.Uri, 612 + Cid: lsOutput.Cid, 613 + }, nil 614 + }
+73
pkg/spxrpc/place_stream_playback.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + 11 + "github.com/labstack/echo/v4" 12 + "github.com/pion/webrtc/v4" 13 + "github.com/streamplace/oatproxy/pkg/oatproxy" 14 + "stream.place/streamplace/pkg/constants" 15 + ) 16 + 17 + func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context, rendition string, streamer string, r io.Reader, _contentType string) (io.Reader, error) { 18 + if alias, ok := s.aliases[streamer]; ok { 19 + streamer = alias 20 + } 21 + 22 + if streamer == "" { 23 + return nil, echo.NewHTTPError(http.StatusBadRequest, "streamer is required") 24 + } 25 + if rendition == "" { 26 + return nil, echo.NewHTTPError(http.StatusBadRequest, "rendition is required") 27 + } 28 + viewer := "" 29 + if !s.cli.WideOpen && !strings.HasPrefix(streamer, constants.DID_KEY_PREFIX) { 30 + repo, err := s.ATSync.SyncBlueskyRepoCached(ctx, streamer) 31 + if err != nil { 32 + return nil, err 33 + } 34 + streamer = repo.DID 35 + } 36 + session, _ := oatproxy.GetOAuthSession(ctx) 37 + if session != nil { 38 + viewer = session.DID 39 + } else { 40 + svc := GetServiceAuth(ctx) 41 + if svc != nil { 42 + // this is a signed request from a peer node, allow them to see unpublished streams 43 + viewer = streamer 44 + } 45 + } 46 + body, err := io.ReadAll(r) 47 + if err != nil { 48 + return nil, echo.NewHTTPError(http.StatusBadRequest, "error reading body", err) 49 + } 50 + offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} 51 + if streamer == viewer { 52 + // this user gets sent right to the origin in case we're unpublished 53 + origin, err := s.statefulDB.GetLatestBroadcastOriginForStreamer(streamer) 54 + if err != nil { 55 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error getting broadcast origin", err) 56 + } 57 + myDID := s.cli.ServerDID() 58 + if origin != nil && origin.ServerDID != myDID { 59 + data, err := s.ProxyServiceRequest(ctx, origin.ServerDID, "POST", "place.stream.playback.whep", 60 + url.Values{"rendition": {rendition}, "streamer": {streamer}}, 61 + bytes.NewReader([]byte(offer.SDP)), _contentType) 62 + if err != nil { 63 + return nil, err 64 + } 65 + return bytes.NewReader(data), nil 66 + } 67 + } 68 + answer, err := s.mm.WebRTCPlayback2(ctx, streamer, rendition, &offer, viewer) 69 + if err != nil { 70 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error playing back", err) 71 + } 72 + return bytes.NewReader([]byte(answer.SDP)), nil 73 + }
+152
pkg/spxrpc/service_auth.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + "time" 11 + 12 + "github.com/labstack/echo/v4" 13 + "github.com/lestrrat-go/jwx/v2/jwa" 14 + "github.com/lestrrat-go/jwx/v2/jwk" 15 + "github.com/lestrrat-go/jwx/v2/jwt" 16 + "stream.place/streamplace/pkg/aqhttp" 17 + "stream.place/streamplace/pkg/log" 18 + ) 19 + 20 + const serviceAuthHeader = "X-Streamplace-Service-Auth" 21 + const serviceTokenLifetime = 5 * time.Minute 22 + 23 + // ServiceIdentity represents an authenticated peer node in the same station. 24 + type ServiceIdentity struct { 25 + DID string 26 + } 27 + 28 + type serviceAuthContextKeyType struct{} 29 + 30 + var serviceAuthContextKey = serviceAuthContextKeyType{} 31 + 32 + // GetServiceAuth returns the authenticated service identity from the context, 33 + // or nil if the request did not come from an authenticated peer node. 34 + func GetServiceAuth(ctx context.Context) *ServiceIdentity { 35 + v := ctx.Value(serviceAuthContextKey) 36 + if v == nil { 37 + return nil 38 + } 39 + identity, ok := v.(*ServiceIdentity) 40 + if !ok { 41 + return nil 42 + } 43 + return identity 44 + } 45 + 46 + // ServiceAuthMiddleware checks for a service-to-service JWT in the 47 + // X-Streamplace-Service-Auth header. If present and valid, it populates 48 + // the context with the caller's ServiceIdentity. If absent or invalid, 49 + // the request passes through unchanged for the normal OAuth flow. 50 + func (s *Server) ServiceAuthMiddleware() echo.MiddlewareFunc { 51 + return func(next echo.HandlerFunc) echo.HandlerFunc { 52 + return func(c echo.Context) error { 53 + tokenStr := c.Request().Header.Get(serviceAuthHeader) 54 + if tokenStr == "" { 55 + return next(c) 56 + } 57 + 58 + ctx := c.Request().Context() 59 + key := s.cli.ServiceAuthKey 60 + if key == nil { 61 + log.Warn(ctx, "service auth token present but no service auth key configured") 62 + return next(c) 63 + } 64 + 65 + token, err := jwt.Parse([]byte(tokenStr), jwt.WithKey(jwa.HS256, key), jwt.WithValidate(true)) 66 + if err != nil { 67 + log.Warn(ctx, "invalid service auth token", "error", err) 68 + return next(c) 69 + } 70 + 71 + issuer := token.Issuer() 72 + if issuer == "" { 73 + log.Warn(ctx, "service auth token missing issuer claim") 74 + return next(c) 75 + } 76 + 77 + identity := &ServiceIdentity{DID: issuer} 78 + ctx = context.WithValue(ctx, serviceAuthContextKey, identity) 79 + c.SetRequest(c.Request().WithContext(ctx)) 80 + log.Log(ctx, "got authenticated service request", "service_did", issuer) 81 + return next(c) 82 + } 83 + } 84 + } 85 + 86 + // CreateServiceToken generates a signed JWT for authenticating this node 87 + // to another node in the same station. 88 + func CreateServiceToken(key jwk.Key, serverDID string) (string, error) { 89 + now := time.Now() 90 + token, err := jwt.NewBuilder(). 91 + Issuer(serverDID). 92 + IssuedAt(now). 93 + Expiration(now.Add(serviceTokenLifetime)). 94 + Build() 95 + if err != nil { 96 + return "", fmt.Errorf("failed to build service token: %w", err) 97 + } 98 + 99 + signed, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, key)) 100 + if err != nil { 101 + return "", fmt.Errorf("failed to sign service token: %w", err) 102 + } 103 + 104 + return string(signed), nil 105 + } 106 + 107 + // DIDWebToHost extracts the hostname from a did:web DID. 108 + func DIDWebToHost(did string) string { 109 + return strings.TrimPrefix(did, "did:web:") 110 + } 111 + 112 + // ProxyServiceRequest proxies an XRPC request to a peer node, authenticating 113 + // with a service token. Returns the response body bytes or an echo.HTTPError. 114 + func (s *Server) ProxyServiceRequest(ctx context.Context, targetDID, httpMethod, xrpcMethod string, query url.Values, body io.Reader, contentType string) ([]byte, error) { 115 + token, err := CreateServiceToken(s.cli.ServiceAuthKey, s.cli.ServerDID()) 116 + if err != nil { 117 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error creating service token: "+err.Error()) 118 + } 119 + 120 + u := url.URL{ 121 + Scheme: "https", 122 + Host: DIDWebToHost(targetDID), 123 + Path: "/xrpc/" + xrpcMethod, 124 + RawQuery: query.Encode(), 125 + } 126 + 127 + log.Log(ctx, "proxying service request", "target", targetDID, "method", xrpcMethod) 128 + 129 + req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), body) 130 + if err != nil { 131 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to construct proxy request: "+err.Error()) 132 + } 133 + if contentType != "" { 134 + req.Header.Set("Content-Type", contentType) 135 + } 136 + req.Header.Set(serviceAuthHeader, token) 137 + 138 + resp, err := aqhttp.Client.Do(req) 139 + if err != nil { 140 + return nil, echo.NewHTTPError(http.StatusBadGateway, "error proxying to peer: "+err.Error()) 141 + } 142 + defer resp.Body.Close() 143 + 144 + data, err := io.ReadAll(resp.Body) 145 + if err != nil { 146 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "error reading peer response: "+err.Error()) 147 + } 148 + if resp.StatusCode != http.StatusOK { 149 + return nil, echo.NewHTTPError(resp.StatusCode, "peer error: "+string(data)) 150 + } 151 + return data, nil 152 + }
+9 -3
pkg/spxrpc/spxrpc.go
··· 20 20 "stream.place/streamplace/pkg/config" 21 21 "stream.place/streamplace/pkg/localdb" 22 22 "stream.place/streamplace/pkg/log" 23 + "stream.place/streamplace/pkg/media" 23 24 "stream.place/streamplace/pkg/model" 24 25 "stream.place/streamplace/pkg/statedb" 25 26 ) ··· 35 36 bus *bus.Bus 36 37 op *oatproxy.OATProxy 37 38 localDB localdb.LocalDB 39 + mm *media.MediaManager 40 + aliases map[string]string 38 41 } 39 42 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) { 43 + func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB, mm *media.MediaManager, aliases map[string]string) (*Server, error) { 41 44 e := echo.New() 42 45 s := &Server{ 43 46 e: e, ··· 50 53 bus: bus, 51 54 op: op, 52 55 localDB: ldb, 56 + mm: mm, 57 + aliases: aliases, 53 58 } 54 59 e.Use(s.ErrorHandlingMiddleware()) 55 60 e.Use(s.ContextPreservingMiddleware()) 56 61 e.Use(echomiddleware.Handler("", mdlw)) 62 + e.Use(s.ServiceAuthMiddleware()) 57 63 e.Use(op.OAuthMiddleware) 58 64 err := s.RegisterHandlersPlaceStream(e) 59 65 if err != nil { ··· 82 88 if err != nil { 83 89 return false, "", fmt.Errorf("resolveRepoService: %w", err) 84 90 } 85 - if did == s.cli.MyDID() { 91 + if did == s.cli.BroadcasterDID() { 86 92 return true, svc, nil 87 93 } 88 94 return false, svc, nil ··· 103 109 } 104 110 u.RawQuery = query.Encode() 105 111 106 - log.Error(ctx, "making unauthenticated request", "url", u.String()) 112 + log.Debug(ctx, "making unauthenticated request", "url", u.String()) 107 113 108 114 req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) 109 115 if err != nil {
+56
pkg/spxrpc/stubs.go
··· 291 291 e.GET("/xrpc/place.stream.live.getRecommendations", s.HandlePlaceStreamLiveGetRecommendations) 292 292 e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) 293 293 e.GET("/xrpc/place.stream.live.searchActorsTypeahead", s.HandlePlaceStreamLiveSearchActorsTypeahead) 294 + e.POST("/xrpc/place.stream.live.startLivestream", s.HandlePlaceStreamLiveStartLivestream) 295 + e.POST("/xrpc/place.stream.live.stopLivestream", s.HandlePlaceStreamLiveStopLivestream) 294 296 e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock) 295 297 e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate) 296 298 e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock) ··· 300 302 e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget) 301 303 e.GET("/xrpc/place.stream.multistream.listTargets", s.HandlePlaceStreamMultistreamListTargets) 302 304 e.POST("/xrpc/place.stream.multistream.putTarget", s.HandlePlaceStreamMultistreamPutTarget) 305 + e.POST("/xrpc/place.stream.playback.whep", s.HandlePlaceStreamPlaybackWhep) 303 306 e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) 304 307 e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) 305 308 e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) ··· 538 541 return c.JSON(200, out) 539 542 } 540 543 544 + func (s *Server) HandlePlaceStreamLiveStartLivestream(c echo.Context) error { 545 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveStartLivestream") 546 + defer span.End() 547 + 548 + var body placestream.LiveStartLivestream_Input 549 + if err := c.Bind(&body); err != nil { 550 + return err 551 + } 552 + var out *placestream.LiveStartLivestream_Output 553 + var handleErr error 554 + // func (s *Server) handlePlaceStreamLiveStartLivestream(ctx context.Context,body *placestream.LiveStartLivestream_Input) (*placestream.LiveStartLivestream_Output, error) 555 + out, handleErr = s.handlePlaceStreamLiveStartLivestream(ctx, &body) 556 + if handleErr != nil { 557 + return handleErr 558 + } 559 + return c.JSON(200, out) 560 + } 561 + 562 + func (s *Server) HandlePlaceStreamLiveStopLivestream(c echo.Context) error { 563 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamLiveStopLivestream") 564 + defer span.End() 565 + 566 + var body placestream.LiveStopLivestream_Input 567 + if err := c.Bind(&body); err != nil { 568 + return err 569 + } 570 + var out *placestream.LiveStopLivestream_Output 571 + var handleErr error 572 + // func (s *Server) handlePlaceStreamLiveStopLivestream(ctx context.Context,body *placestream.LiveStopLivestream_Input) (*placestream.LiveStopLivestream_Output, error) 573 + out, handleErr = s.handlePlaceStreamLiveStopLivestream(ctx, &body) 574 + if handleErr != nil { 575 + return handleErr 576 + } 577 + return c.JSON(200, out) 578 + } 579 + 541 580 func (s *Server) HandlePlaceStreamModerationCreateBlock(c echo.Context) error { 542 581 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreateBlock") 543 582 defer span.End() ··· 705 744 return handleErr 706 745 } 707 746 return c.JSON(200, out) 747 + } 748 + 749 + func (s *Server) HandlePlaceStreamPlaybackWhep(c echo.Context) error { 750 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamPlaybackWhep") 751 + defer span.End() 752 + rendition := c.QueryParam("rendition") 753 + streamer := c.QueryParam("streamer") 754 + body := c.Request().Body 755 + contentType := c.Request().Header.Get("Content-Type") 756 + var out io.Reader 757 + var handleErr error 758 + // func (s *Server) handlePlaceStreamPlaybackWhep(ctx context.Context,rendition string,streamer string,r io.Reader,contentType string) (io.Reader, error) 759 + out, handleErr = s.handlePlaceStreamPlaybackWhep(ctx, rendition, streamer, body, contentType) 760 + if handleErr != nil { 761 + return handleErr 762 + } 763 + return c.Stream(200, "application/octet-stream", out) 708 764 } 709 765 710 766 func (s *Server) HandlePlaceStreamServerCreateWebhook(c echo.Context) error {
+51
pkg/statedb/broadcast_origin.go
··· 1 + package statedb 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type BroadcastOrigin struct { 8 + StreamerRepoDID string `gorm:"column:streamer_repo_did;primarykey;index:idx_streamer_repo_did_updated_at,priority:1"` 9 + ServerDID string `gorm:"column:server_did;primarykey;index:idx_server_did_updated_at,priority:1"` 10 + UpdatedAt time.Time `gorm:"column:updated_at;index:idx_streamer_repo_did_updated_at,priority:2;index:idx_server_did_updated_at,priority:2"` 11 + } 12 + 13 + func (m *BroadcastOrigin) TableName() string { 14 + return "broadcast_origins" 15 + } 16 + 17 + // UpsertBroadcastOrigin inserts or updates a BroadcastOrigin entry. 18 + // If an entry with the same StreamerRepoDID and ServerRepoDID exists, it updates UpdatedAt. 19 + // Otherwise, it creates a new entry. 20 + func (state *StatefulDB) UpsertBroadcastOrigin(streamerRepoDID, serverRepoDID string, updatedAt time.Time) error { 21 + broadcastOrigin := &BroadcastOrigin{ 22 + StreamerRepoDID: streamerRepoDID, 23 + ServerDID: serverRepoDID, 24 + UpdatedAt: updatedAt, 25 + } 26 + // Uses GORM's upsert ("ON CONFLICT DO UPDATE") by providing primary keys and using Updates 27 + return state.DB. 28 + Clauses( 29 + // GORM uses these settings to upsert 30 + // The clause 'ON CONFLICT (primary key) DO UPDATE' is default when calling Save 31 + ). 32 + Save(broadcastOrigin).Error 33 + } 34 + 35 + // GetLatestBroadcastOriginForStreamer retrieves the most recent BroadcastOrigin for a given streamerRepoDID, 36 + // ordered by UpdatedAt descending, and returns the first found. 37 + func (state *StatefulDB) GetLatestBroadcastOriginForStreamer(streamerRepoDID string) (*BroadcastOrigin, error) { 38 + var origin BroadcastOrigin 39 + tx := state.DB. 40 + Where("streamer_repo_did = ?", streamerRepoDID). 41 + Order("updated_at DESC"). 42 + Limit(1). 43 + Find(&origin) 44 + if tx.Error != nil { 45 + return nil, tx.Error 46 + } 47 + if tx.RowsAffected == 0 { 48 + return nil, nil 49 + } 50 + return &origin, nil 51 + }
+93
pkg/statedb/queue_processor.go
··· 8 8 "time" 9 9 10 10 "github.com/bluesky-social/indigo/api/bsky" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + "github.com/bluesky-social/indigo/xrpc" 11 14 "gorm.io/gorm" 12 15 "stream.place/streamplace/pkg/integrations/webhook" 13 16 "stream.place/streamplace/pkg/log" 14 17 notificationpkg "stream.place/streamplace/pkg/notifications" 15 18 "stream.place/streamplace/pkg/streamplace" 19 + 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 16 21 ) 17 22 18 23 var TaskNotification = "notification" 19 24 var TaskChat = "chat" 25 + var TaskFinalizeLivestream = "finalize_livestream" 20 26 21 27 type NotificationTask struct { 22 28 Livestream *streamplace.Livestream_LivestreamView ··· 27 33 28 34 type ChatTask struct { 29 35 MessageView *streamplace.ChatDefs_MessageView 36 + } 37 + 38 + type FinalizeLivestreamTask struct { 39 + LivestreamURI string `json:"livestreamURI"` 30 40 } 31 41 32 42 func (state *StatefulDB) ProcessQueue(ctx context.Context) error { ··· 60 70 return state.processNotificationTask(ctx, task) 61 71 case TaskChat: 62 72 return state.processChatMessageTask(ctx, task) 73 + case TaskFinalizeLivestream: 74 + return state.processFinalizeLivestreamTask(ctx, task) 63 75 default: 64 76 return fmt.Errorf("unknown task type: %s", task.Type) 65 77 } 78 + } 79 + 80 + func (state *StatefulDB) processFinalizeLivestreamTask(ctx context.Context, task *AppTask) error { 81 + ctx = log.WithLogValues(ctx, "func", "processFinalizeLivestreamTask") 82 + log.Debug(ctx, "processing finalize livestream task") 83 + log.Warn(ctx, "processing finalize livestream task") 84 + var finalizeLivestreamTask FinalizeLivestreamTask 85 + if err := json.Unmarshal(task.Payload, &finalizeLivestreamTask); err != nil { 86 + return err 87 + } 88 + livestream, err := state.model.GetLivestream(finalizeLivestreamTask.LivestreamURI) 89 + if err != nil { 90 + return fmt.Errorf("failed to get latest livestream for userDID: %w", err) 91 + } 92 + if livestream == nil { 93 + return fmt.Errorf("no livestream found for URI: %s", finalizeLivestreamTask.LivestreamURI) 94 + } 95 + lastLivestreamView, err := livestream.ToLivestreamView() 96 + if err != nil { 97 + return fmt.Errorf("failed to convert livestream to streamplace livestream: %w", err) 98 + } 99 + rec, ok := lastLivestreamView.Record.Val.(*streamplace.Livestream) 100 + if !ok { 101 + return fmt.Errorf("livestream is not a streamplace livestream") 102 + } 103 + if rec.LastSeenAt == nil { 104 + return fmt.Errorf("livestream has no last seen at") 105 + } 106 + lastSeenTime, err := time.Parse(time.RFC3339, *rec.LastSeenAt) 107 + if err != nil { 108 + return fmt.Errorf("could not parse last seen at: %w", err) 109 + } 110 + if rec.IdleTimeoutSeconds == nil || *rec.IdleTimeoutSeconds == 0 { 111 + log.Debug(ctx, "livestream has no idle timeout, skipping finalization", "uri", livestream.URI) 112 + return nil 113 + } 114 + if time.Since(lastSeenTime) < (time.Duration(*rec.IdleTimeoutSeconds) * time.Second) { 115 + log.Debug(ctx, "livestream is active, skipping finalization", "lastSeenAt", lastSeenTime) 116 + return nil 117 + } 118 + session, err := state.GetSessionByDID(livestream.RepoDID) 119 + if err != nil { 120 + return fmt.Errorf("failed to get session: %w", err) 121 + } 122 + session, err = state.OATProxy.RefreshIfNeeded(session) 123 + if err != nil { 124 + return fmt.Errorf("failed to refresh session: %w", err) 125 + } 126 + client, err := state.OATProxy.GetXrpcClient(session) 127 + if err != nil { 128 + return fmt.Errorf("failed to get xrpc client: %w", err) 129 + } 130 + if rec.EndedAt != nil { 131 + log.Debug(ctx, "livestream has already ended, skipping", "uri", livestream.URI, "endedAt", *rec.EndedAt) 132 + return nil 133 + } 134 + 135 + uri, err := syntax.ParseATURI(livestream.URI) 136 + if err != nil { 137 + return fmt.Errorf("failed to parse ATURI: %w", err) 138 + } 139 + 140 + rec.EndedAt = rec.LastSeenAt 141 + 142 + inp := comatproto.RepoPutRecord_Input{ 143 + Collection: "place.stream.livestream", 144 + Record: &lexutil.LexiconTypeDecoder{Val: rec}, 145 + Rkey: uri.RecordKey().String(), 146 + Repo: livestream.RepoDID, 147 + SwapRecord: &livestream.CID, 148 + } 149 + out := comatproto.RepoPutRecord_Output{} 150 + 151 + err = client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", map[string]any{}, inp, &out) 152 + if err != nil { 153 + return fmt.Errorf("failed to update livestream record: %w", err) 154 + } 155 + 156 + log.Log(ctx, "livestream finalized", "uri", livestream.URI, "endedAt", *rec.EndedAt) 157 + 158 + return nil 66 159 } 67 160 68 161 func (state *StatefulDB) processNotificationTask(ctx context.Context, task *AppTask) error {
+52
pkg/statedb/service_auth.go
··· 1 + package statedb 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "encoding/json" 7 + "fmt" 8 + 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + "stream.place/streamplace/pkg/log" 11 + ) 12 + 13 + // EnsureServiceAuthKey ensures a shared symmetric key exists in the config table 14 + // for intra-service JWT authentication. All nodes sharing the same database will 15 + // use the same key, enabling mutual authentication within a station. 16 + func (state *StatefulDB) EnsureServiceAuthKey(ctx context.Context) (jwk.Key, error) { 17 + conf, err := state.GetConfig("service-auth-key") 18 + if err != nil { 19 + return nil, fmt.Errorf("failed to get service auth key: %w", err) 20 + } 21 + 22 + if conf != nil { 23 + key, err := jwk.ParseKey(conf.Value) 24 + if err != nil { 25 + return nil, fmt.Errorf("failed to parse service auth key: %w", err) 26 + } 27 + return key, nil 28 + } 29 + 30 + log.Warn(ctx, "no service auth key found, generating new one") 31 + 32 + secret := make([]byte, 32) 33 + if _, err := rand.Read(secret); err != nil { 34 + return nil, fmt.Errorf("failed to generate random bytes: %w", err) 35 + } 36 + 37 + key, err := jwk.FromRaw(secret) 38 + if err != nil { 39 + return nil, fmt.Errorf("failed to create symmetric key: %w", err) 40 + } 41 + 42 + b, err := json.Marshal(key) 43 + if err != nil { 44 + return nil, fmt.Errorf("failed to marshal service auth key: %w", err) 45 + } 46 + 47 + if err := state.PutConfig("service-auth-key", b); err != nil { 48 + return nil, fmt.Errorf("failed to save service auth key: %w", err) 49 + } 50 + 51 + return key, nil 52 + }
+2 -1
pkg/statedb/statedb.go
··· 37 37 // pgLockConn is used to hold a connection to the database for locking 38 38 pgLockConn *gorm.DB 39 39 pgLockConnMu sync.Mutex 40 - op *oatproxy.OATProxy 40 + OATProxy *oatproxy.OATProxy 41 41 } 42 42 43 43 // list tables here so we can migrate them ··· 53 53 MultistreamEvent{}, 54 54 BrandingBlob{}, 55 55 ModerationAuditLog{}, 56 + BroadcastOrigin{}, 56 57 } 57 58 58 59 var NoPostgresDatabaseCode = "3D000"
+2 -2
pkg/statedb/task.go
··· 105 105 err := state.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 106 106 query := tx.Where("status = ?", TaskStatusPending). 107 107 Where("try_count < max_tries"). 108 - Where("(lock_expires IS NULL OR lock_expires < ?)", time.Now()). 109 - Where("(scheduled_at IS NULL OR scheduled_at <= ?)", time.Now()) 108 + Where("(lock_expires IS NULL OR lock_expires < ?)", time.Now().UTC()). 109 + Where("(scheduled_at IS NULL OR scheduled_at <= ?)", time.Now().UTC()) 110 110 111 111 if len(taskTypes) > 0 { 112 112 query = query.Where("type IN ?", taskTypes)
+187 -1
pkg/streamplace/cbor_gen.go
··· 250 250 } 251 251 252 252 cw := cbg.NewCborWriter(w) 253 - fieldCount := 9 253 + fieldCount := 12 254 254 255 255 if t.Agent == nil { 256 256 fieldCount-- 257 257 } 258 258 259 259 if t.CanonicalUrl == nil { 260 + fieldCount-- 261 + } 262 + 263 + if t.EndedAt == nil { 264 + fieldCount-- 265 + } 266 + 267 + if t.IdleTimeoutSeconds == nil { 268 + fieldCount-- 269 + } 270 + 271 + if t.LastSeenAt == nil { 260 272 fieldCount-- 261 273 } 262 274 ··· 424 436 return err 425 437 } 426 438 439 + // t.EndedAt (string) (string) 440 + if t.EndedAt != nil { 441 + 442 + if len("endedAt") > 1000000 { 443 + return xerrors.Errorf("Value in field \"endedAt\" was too long") 444 + } 445 + 446 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("endedAt"))); err != nil { 447 + return err 448 + } 449 + if _, err := cw.WriteString(string("endedAt")); err != nil { 450 + return err 451 + } 452 + 453 + if t.EndedAt == nil { 454 + if _, err := cw.Write(cbg.CborNull); err != nil { 455 + return err 456 + } 457 + } else { 458 + if len(*t.EndedAt) > 1000000 { 459 + return xerrors.Errorf("Value in field t.EndedAt was too long") 460 + } 461 + 462 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.EndedAt))); err != nil { 463 + return err 464 + } 465 + if _, err := cw.WriteString(string(*t.EndedAt)); err != nil { 466 + return err 467 + } 468 + } 469 + } 470 + 427 471 // t.CreatedAt (string) (string) 428 472 if len("createdAt") > 1000000 { 429 473 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 447 491 return err 448 492 } 449 493 494 + // t.LastSeenAt (string) (string) 495 + if t.LastSeenAt != nil { 496 + 497 + if len("lastSeenAt") > 1000000 { 498 + return xerrors.Errorf("Value in field \"lastSeenAt\" was too long") 499 + } 500 + 501 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lastSeenAt"))); err != nil { 502 + return err 503 + } 504 + if _, err := cw.WriteString(string("lastSeenAt")); err != nil { 505 + return err 506 + } 507 + 508 + if t.LastSeenAt == nil { 509 + if _, err := cw.Write(cbg.CborNull); err != nil { 510 + return err 511 + } 512 + } else { 513 + if len(*t.LastSeenAt) > 1000000 { 514 + return xerrors.Errorf("Value in field t.LastSeenAt was too long") 515 + } 516 + 517 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.LastSeenAt))); err != nil { 518 + return err 519 + } 520 + if _, err := cw.WriteString(string(*t.LastSeenAt)); err != nil { 521 + return err 522 + } 523 + } 524 + } 525 + 450 526 // t.CanonicalUrl (string) (string) 451 527 if t.CanonicalUrl != nil { 452 528 ··· 479 555 } 480 556 } 481 557 558 + // t.IdleTimeoutSeconds (int64) (int64) 559 + if t.IdleTimeoutSeconds != nil { 560 + 561 + if len("idleTimeoutSeconds") > 1000000 { 562 + return xerrors.Errorf("Value in field \"idleTimeoutSeconds\" was too long") 563 + } 564 + 565 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("idleTimeoutSeconds"))); err != nil { 566 + return err 567 + } 568 + if _, err := cw.WriteString(string("idleTimeoutSeconds")); err != nil { 569 + return err 570 + } 571 + 572 + if t.IdleTimeoutSeconds == nil { 573 + if _, err := cw.Write(cbg.CborNull); err != nil { 574 + return err 575 + } 576 + } else { 577 + if *t.IdleTimeoutSeconds >= 0 { 578 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.IdleTimeoutSeconds)); err != nil { 579 + return err 580 + } 581 + } else { 582 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.IdleTimeoutSeconds-1)); err != nil { 583 + return err 584 + } 585 + } 586 + } 587 + 588 + } 589 + 482 590 // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct) 483 591 if t.NotificationSettings != nil { 484 592 ··· 645 753 646 754 t.Title = string(sval) 647 755 } 756 + // t.EndedAt (string) (string) 757 + case "endedAt": 758 + 759 + { 760 + b, err := cr.ReadByte() 761 + if err != nil { 762 + return err 763 + } 764 + if b != cbg.CborNull[0] { 765 + if err := cr.UnreadByte(); err != nil { 766 + return err 767 + } 768 + 769 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 770 + if err != nil { 771 + return err 772 + } 773 + 774 + t.EndedAt = (*string)(&sval) 775 + } 776 + } 648 777 // t.CreatedAt (string) (string) 649 778 case "createdAt": 650 779 ··· 656 785 657 786 t.CreatedAt = string(sval) 658 787 } 788 + // t.LastSeenAt (string) (string) 789 + case "lastSeenAt": 790 + 791 + { 792 + b, err := cr.ReadByte() 793 + if err != nil { 794 + return err 795 + } 796 + if b != cbg.CborNull[0] { 797 + if err := cr.UnreadByte(); err != nil { 798 + return err 799 + } 800 + 801 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 802 + if err != nil { 803 + return err 804 + } 805 + 806 + t.LastSeenAt = (*string)(&sval) 807 + } 808 + } 659 809 // t.CanonicalUrl (string) (string) 660 810 case "canonicalUrl": 661 811 ··· 675 825 } 676 826 677 827 t.CanonicalUrl = (*string)(&sval) 828 + } 829 + } 830 + // t.IdleTimeoutSeconds (int64) (int64) 831 + case "idleTimeoutSeconds": 832 + { 833 + 834 + b, err := cr.ReadByte() 835 + if err != nil { 836 + return err 837 + } 838 + if b != cbg.CborNull[0] { 839 + if err := cr.UnreadByte(); err != nil { 840 + return err 841 + } 842 + maj, extra, err := cr.ReadHeader() 843 + if err != nil { 844 + return err 845 + } 846 + var extraI int64 847 + switch maj { 848 + case cbg.MajUnsignedInt: 849 + extraI = int64(extra) 850 + if extraI < 0 { 851 + return fmt.Errorf("int64 positive overflow") 852 + } 853 + case cbg.MajNegativeInt: 854 + extraI = int64(extra) 855 + if extraI < 0 { 856 + return fmt.Errorf("int64 negative overflow") 857 + } 858 + extraI = -1 - extraI 859 + default: 860 + return fmt.Errorf("wrong type for int64 field: %d", maj) 861 + } 862 + 863 + t.IdleTimeoutSeconds = (*int64)(&extraI) 678 864 } 679 865 } 680 866 // t.NotificationSettings (streamplace.Livestream_NotificationSettings) (struct)
+38
pkg/streamplace/livestartLivestream.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.startLivestream 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // LiveStartLivestream_Input is the input argument to a place.stream.live.startLivestream call. 14 + type LiveStartLivestream_Input struct { 15 + // createBlueskyPost: Whether to create a Bluesky post announcing the livestream. 16 + CreateBlueskyPost *bool `json:"createBlueskyPost,omitempty" cborgen:"createBlueskyPost,omitempty"` 17 + Livestream *Livestream `json:"livestream" cborgen:"livestream"` 18 + // streamer: The DID of the streamer. 19 + Streamer string `json:"streamer" cborgen:"streamer"` 20 + } 21 + 22 + // LiveStartLivestream_Output is the output of a place.stream.live.startLivestream call. 23 + type LiveStartLivestream_Output struct { 24 + // cid: The CID of the livestream record. 25 + Cid string `json:"cid" cborgen:"cid"` 26 + // uri: The URI of the livestream record. 27 + Uri string `json:"uri" cborgen:"uri"` 28 + } 29 + 30 + // LiveStartLivestream calls the XRPC method "place.stream.live.startLivestream". 31 + func LiveStartLivestream(ctx context.Context, c lexutil.LexClient, input *LiveStartLivestream_Input) (*LiveStartLivestream_Output, error) { 32 + var out LiveStartLivestream_Output 33 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.live.startLivestream", nil, input, &out); err != nil { 34 + return nil, err 35 + } 36 + 37 + return &out, nil 38 + }
+33
pkg/streamplace/livestopLivestream.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.live.stopLivestream 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // LiveStopLivestream_Input is the input argument to a place.stream.live.stopLivestream call. 14 + type LiveStopLivestream_Input struct { 15 + } 16 + 17 + // LiveStopLivestream_Output is the output of a place.stream.live.stopLivestream call. 18 + type LiveStopLivestream_Output struct { 19 + // cid: The new CID of the stopped livestream record. 20 + Cid string `json:"cid" cborgen:"cid"` 21 + // uri: The URI of the stopped livestream record. 22 + Uri string `json:"uri" cborgen:"uri"` 23 + } 24 + 25 + // LiveStopLivestream calls the XRPC method "place.stream.live.stopLivestream". 26 + func LiveStopLivestream(ctx context.Context, c lexutil.LexClient, input *LiveStopLivestream_Input) (*LiveStopLivestream_Output, error) { 27 + var out LiveStopLivestream_Output 28 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.live.stopLivestream", nil, input, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+30
pkg/streamplace/playbackwhep.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.playback.whep 4 + 5 + package streamplace 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + "io" 11 + 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + ) 14 + 15 + // PlaybackWhep calls the XRPC method "place.stream.playback.whep". 16 + // 17 + // rendition: The rendition of the stream to play. 18 + // streamer: The DID of the streamer to play. 19 + func PlaybackWhep(ctx context.Context, c lexutil.LexClient, input io.Reader, rendition string, streamer string) ([]byte, error) { 20 + buf := new(bytes.Buffer) 21 + 22 + params := map[string]interface{}{} 23 + params["rendition"] = rendition 24 + params["streamer"] = streamer 25 + if err := c.LexDo(ctx, lexutil.Procedure, "*/*", "place.stream.playback.whep", params, input, buf); err != nil { 26 + return nil, err 27 + } 28 + 29 + return buf.Bytes(), nil 30 + }
+7 -1
pkg/streamplace/streamlivestream.go
··· 24 24 // canonicalUrl: The primary URL where this livestream can be viewed, if available. 25 25 CanonicalUrl *string `json:"canonicalUrl,omitempty" cborgen:"canonicalUrl,omitempty"` 26 26 // createdAt: Client-declared timestamp when this livestream started. 27 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // endedAt: Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. 29 + EndedAt *string `json:"endedAt,omitempty" cborgen:"endedAt,omitempty"` 30 + // idleTimeoutSeconds: Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. 31 + IdleTimeoutSeconds *int64 `json:"idleTimeoutSeconds,omitempty" cborgen:"idleTimeoutSeconds,omitempty"` 32 + // lastSeenAt: Client-declared timestamp when this livestream was last seen by the Streamplace station. 33 + LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"` 28 34 NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"` 29 35 // post: The post that announced this livestream. 30 36 Post *comatproto.RepoStrongRef `json:"post,omitempty" cborgen:"post,omitempty"`