Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+3568 -978
+5 -4
Makefile
··· 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 387 && for x in $$(find ./js/streamplace/src/lexicons -type f -name '*.ts'); do \ 388 - echo 'import { AppBskyRichtextFacet, AppBskyGraphBlock, ComAtprotoRepoStrongRef, AppBskyActorDefs, ComAtprotoSyncListRepos, AppBskyActorGetProfile, AppBskyFeedGetFeedSkeleton, ComAtprotoIdentityResolveHandle, ComAtprotoModerationCreateReport, ComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoDescribeRepo, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord, ComAtprotoRepoUploadBlob, ComAtprotoServerDescribeServer, ComAtprotoSyncGetRecord, ComAtprotoSyncListReposComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoIdentityRefreshIdentity } from "@atproto/api"' >> $$x; \ 388 + echo 'import { ComAtprotoSyncGetRepo, AppBskyRichtextFacet, AppBskyGraphBlock, ComAtprotoRepoStrongRef, AppBskyActorDefs, ComAtprotoSyncListRepos, AppBskyActorGetProfile, AppBskyFeedGetFeedSkeleton, ComAtprotoIdentityResolveHandle, ComAtprotoModerationCreateReport, ComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoDescribeRepo, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord, ComAtprotoRepoUploadBlob, ComAtprotoServerDescribeServer, ComAtprotoSyncGetRecord, ComAtprotoSyncListReposComAtprotoRepoCreateRecord, ComAtprotoRepoDeleteRecord, ComAtprotoRepoGetRecord, ComAtprotoRepoListRecords, ComAtprotoIdentityRefreshIdentity } from "@atproto/api"' >> $$x; \ 389 389 done \ 390 - && npx prettier --write $$(find ./js/streamplace/src/lexicons -type f -name '*.ts') \ 390 + && npx prettier --ignore-unknown --write $$(find ./js/streamplace/src/lexicons -type f -name '*.ts') \ 391 391 && find . | grep bak$$ | xargs rm 392 392 393 393 .PHONY: md-lexicons 394 394 md-lexicons: 395 - pnpm exec lexmd \ 395 + find "js/docs/src/content/docs/lex-reference" -type f -name '*.md' -delete \ 396 + && pnpm exec lexmd \ 396 397 ./lexicons \ 397 398 .build/temp \ 398 399 subprojects/atproto/lexicons \ ··· 437 438 .PHONY: ci-lexicons 438 439 ci-lexicons: 439 440 $(MAKE) lexicons \ 440 - && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; exit 1; fi 441 + && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; git diff; exit 1; fi 441 442 442 443 # _______ ______ _____ _______ _____ _ _ _____ 443 444 # |__ __| ____|/ ____|__ __|_ _| \ | |/ ____|
+2 -2
go.mod
··· 54 54 github.com/pion/webrtc/v4 v4.0.11 55 55 github.com/piprate/json-gold v0.5.0 56 56 github.com/prometheus/client_golang v1.23.0 57 + github.com/rivo/uniseg v0.4.7 57 58 github.com/rs/cors v1.11.1 58 59 github.com/samber/slog-http v1.4.0 59 60 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 60 61 github.com/slok/go-http-metrics v0.13.0 61 62 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 62 63 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 63 - github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f 64 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b 64 65 github.com/stretchr/testify v1.11.1 65 66 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 66 67 github.com/whyrusleeping/cbor-gen v0.3.1 ··· 425 426 github.com/rabbitmq/amqp091-go v1.8.0 // indirect 426 427 github.com/rabbitmq/rabbitmq-stream-go-client v1.1.1 // indirect 427 428 github.com/raeperd/recvcheck v0.2.0 // indirect 428 - github.com/rivo/uniseg v0.4.7 // indirect 429 429 github.com/rogpeppe/go-internal v1.14.1 // indirect 430 430 github.com/rs/xid v1.5.0 // indirect 431 431 github.com/russross/blackfriday/v2 v2.1.0 // indirect
+2
go.sum
··· 1319 1319 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1320 1320 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= 1321 1321 github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= 1323 + github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1322 1324 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1323 1325 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1324 1326 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+2 -1
js/app/components/follow-button.tsx
··· 123 123 disabled={isFollowing === null} 124 124 loading={isFollowing === null} 125 125 leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />} 126 + hoverStyle={isFollowing ? { backgroundColor: "#dc2626" } : undefined} 126 127 > 127 128 {isFollowing === null 128 129 ? "Loading..." 129 130 : isFollowing 130 - ? "Unfollow" 131 + ? "Following" 131 132 : "Follow"} 132 133 </Button> 133 134 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>}
-2
js/app/components/live-dashboard/bento-grid.tsx
··· 155 155 streamTitle={ 156 156 profile?.displayName || profile?.handle || "Live Stream" 157 157 } 158 - viewers={viewers || 0} 159 158 uptime={getUptime()} 160 159 bitrate={getBitrate()} 161 160 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} ··· 235 234 streamTitle={ 236 235 profile?.displayName || profile?.handle || "Live Stream" 237 236 } 238 - viewers={viewers || 0} 239 237 uptime={getUptime()} 240 238 bitrate={getBitrate()} 241 239 timeBetweenSegments={segmentTiming.timeBetweenSegments || 0}
+61 -12
js/app/components/live-dashboard/livestream-panel.tsx
··· 6 6 Dashboard, 7 7 formatHandle, 8 8 formatHandleWithAt, 9 + getBlob, 9 10 Input, 11 + resolveDIDDocument, 10 12 Text, 11 13 Textarea, 12 14 Tooltip, ··· 82 84 selectedImage, 83 85 onImageSelect, 84 86 onImageRemove, 87 + onUseLastImage, 88 + hasLastImage, 85 89 onGoToMetadata, 86 90 }: { 87 91 selectedImage?: string | File | Blob; 88 92 onImageSelect?: () => void; 89 93 onImageRemove?: () => void; 94 + onUseLastImage?: () => void; 95 + hasLastImage?: boolean; 90 96 onGoToMetadata?: () => void; 91 97 }) => { 92 98 const imageUrl = useMemo(() => { ··· 154 160 </TouchableOpacity> 155 161 </View> 156 162 ) : ( 157 - <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 158 - <ImagePlus size={48} color="#6b7280" /> 159 - <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 160 - Add thumbnail image 161 - </Text> 162 - <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 163 - Optional โ€ข JPG, PNG up to 975KB 164 - </Text> 165 - </TouchableOpacity> 163 + <> 164 + <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 165 + <ImagePlus size={48} color="#6b7280" /> 166 + <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 167 + Add thumbnail image 168 + </Text> 169 + <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 170 + Optional โ€ข JPG, PNG up to 975KB 171 + </Text> 172 + </TouchableOpacity> 173 + {hasLastImage && ( 174 + <Button 175 + variant="secondary" 176 + size="sm" 177 + onPress={onUseLastImage} 178 + style={[{ marginTop: 8 }]} 179 + > 180 + <Text style={[text.gray[300], { fontSize: 14 }]}> 181 + Use Last Image 182 + </Text> 183 + </Button> 184 + )} 185 + </> 166 186 )} 167 187 <View style={{ marginTop: 8 }}> 168 188 <Admonition variant="info" size="sm"> ··· 203 223 204 224 const [createPost, setCreatePost] = useState(true); 205 225 const [sendPushNotification, setSendPushNotification] = useState(true); 206 - const [canonicalUrl, setCanonicalUrl] = useState<string>( 207 - livestream?.record.canonicalUrl || "", 208 - ); 226 + const [canonicalUrl, setCanonicalUrl] = useState<string>(""); 209 227 const defaultCanonicalUrl = useMemo(() => { 210 228 return `${url}/${profile && formatHandle(profile)}`; 211 229 }, [url, profile?.handle]); ··· 214 232 if (!livestream) { 215 233 return; 216 234 } 235 + 236 + // Prefill title with previous stream's title 237 + if (livestream.record.title) { 238 + setTitle(livestream.record.title); 239 + } 240 + 241 + // Prefill canonical URL 217 242 if ( 218 243 livestream.record.canonicalUrl && 219 244 livestream.record.canonicalUrl !== defaultCanonicalUrl 220 245 ) { 221 246 setCanonicalUrl(livestream.record.canonicalUrl); 222 247 } 248 + 249 + // Prefill notification settings 223 250 if ( 224 251 typeof livestream.record.notificationSettings?.pushNotification === 225 252 "boolean" ··· 228 255 livestream.record.notificationSettings.pushNotification, 229 256 ); 230 257 } 258 + 259 + // Prefill post creation preference 231 260 setCreatePost(typeof livestream.record.post !== "undefined"); 232 261 }, [livestream, defaultCanonicalUrl]); 233 262 ··· 337 366 const handleImageRemove = useCallback(() => { 338 367 setSelectedImage(undefined); 339 368 }, []); 369 + 370 + const handleUseLastImage = useCallback(async () => { 371 + if (!livestream?.record.thumb) return; 372 + 373 + try { 374 + const did = livestream.uri.split("/")[2]; 375 + const cid = (livestream.record.thumb.ref as any).$link; 376 + 377 + const didDoc = await resolveDIDDocument(did); 378 + const blob = await getBlob(did, cid, didDoc); 379 + setSelectedImage(blob); 380 + } catch (error) { 381 + console.error("Failed to fetch last image:", error); 382 + toast.show("Error", "Failed to load previous thumbnail", { 383 + duration: 3, 384 + }); 385 + } 386 + }, [livestream, toast]); 340 387 341 388 const disabled = useMemo( 342 389 () => !userIsLive || loading || title.trim() === "", ··· 587 634 selectedImage={selectedImage} 588 635 onImageSelect={handleImageSelect} 589 636 onImageRemove={handleImageRemove} 637 + onUseLastImage={handleUseLastImage} 638 + hasLastImage={!!livestream?.record.thumb} 590 639 onGoToMetadata={() => handleModeChange("metadata")} 591 640 /> 592 641 )}
+5 -1
js/app/components/live-dashboard/stream-monitor.tsx
··· 104 104 <View style={[flex.values[1], layout.flex.center, bg.neutral[900]]}> 105 105 {isLive && userProfile ? ( 106 106 isStreamVisible ? ( 107 - <Player src={userProfile.did} name={userProfile.handle}> 107 + <Player 108 + src={userProfile.did} 109 + name={userProfile.handle} 110 + muted={true} 111 + > 108 112 <DesktopUi /> 109 113 <PlayerUI.ViewerLoadingOverlay /> 110 114 <OfflineCounter isMobile={true} />
+11 -5
js/app/components/login/login-form.tsx
··· 20 20 21 21 interface LoginFormProps { 22 22 onSuccess?: () => void; 23 + onCloseModal?: () => void; 24 + onOpenPdsModal?: () => void; 23 25 } 24 26 25 - export default function LoginForm({ onSuccess }: LoginFormProps) { 27 + export default function LoginForm({ 28 + onSuccess, 29 + onCloseModal, 30 + onOpenPdsModal, 31 + }: LoginFormProps) { 26 32 const { theme } = useTheme(); 27 33 const loginAction = useStore((state) => state.login); 28 34 const openLoginLink = useStore((state) => state.openLoginLink); ··· 74 80 }; 75 81 76 82 const onSignup = () => { 77 - // TODO: remove requirement for oauth-protected-resource in oatproxy 78 - loginAction("https://bsky.social", openLoginLink); 83 + onCloseModal?.(); 84 + onOpenPdsModal?.(); 79 85 }; 80 86 81 87 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; ··· 284 290 ]} 285 291 > 286 292 <Button width="min" onPress={() => onSignup()} variant="ghost"> 287 - <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 293 + <Text style={[{ color: "white" }]}>Sign Up</Text> 288 294 </Button> 289 295 <Button 290 296 onPress={submit} ··· 293 299 width="min" 294 300 loading={loginState.loading} 295 301 > 296 - <Text style={[{ color: "white" }]}>Log in</Text> 302 + <Text style={[{ color: "white" }]}>Log In</Text> 297 303 </Button> 298 304 </View> 299 305 </>
+15 -2
js/app/components/login/login-modal.tsx
··· 6 6 interface LoginModalProps { 7 7 visible: boolean; 8 8 onClose: () => void; 9 + onOpenPdsModal: () => void; 9 10 } 10 11 11 - export default function LoginModal({ visible, onClose }: LoginModalProps) { 12 + export default function LoginModal({ 13 + visible, 14 + onClose, 15 + onOpenPdsModal, 16 + }: LoginModalProps) { 12 17 const { theme, zero: z } = useTheme(); 18 + 19 + if (!visible) { 20 + return null; 21 + } 13 22 14 23 return ( 15 24 <Modal ··· 64 73 </TouchableOpacity> 65 74 </View> 66 75 67 - <LoginForm onSuccess={onClose} /> 76 + <LoginForm 77 + onSuccess={onClose} 78 + onCloseModal={onClose} 79 + onOpenPdsModal={onOpenPdsModal} 80 + /> 68 81 </Pressable> 69 82 </View> 70 83 </Modal>
+3 -1
js/app/components/login/login.tsx
··· 12 12 export default function Login() { 13 13 const { theme } = useTheme(); 14 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 15 + const openPdsModal = useStore((state) => state.openPdsModal); 15 16 const userProfile = useUserProfile(); 16 17 const navigation = useNavigation(); 17 18 const isReady = useIsReady(); ··· 26 27 27 28 // check for stored return route on mount 28 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 29 31 storage.getItem("returnRoute").then((stored) => { 30 32 if (stored) { 31 33 try { ··· 103 105 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 104 106 Log in 105 107 </Text> 106 - <LoginForm /> 108 + <LoginForm onOpenPdsModal={openPdsModal} /> 107 109 </View> 108 110 </View> 109 111 </ScrollView>
+364
js/app/components/login/pds-host-selector-modal.tsx
··· 1 + import { 2 + Admonition, 3 + Button, 4 + Checkbox, 5 + Input, 6 + ResponsiveDialog, 7 + Trans as T, 8 + Text, 9 + useTheme, 10 + useTranslation, 11 + zero, 12 + } from "@streamplace/components"; 13 + import { Check, ExternalLink } from "lucide-react-native"; 14 + import React, { useState } from "react"; 15 + import { Linking, Pressable, View } from "react-native"; 16 + 17 + interface PdsHost { 18 + value: string; 19 + label: string; 20 + description: string; 21 + handlePolicyDocs?: string; 22 + terms: string; 23 + privacy: string; 24 + } 25 + 26 + const PDS_HOSTS = [ 27 + { 28 + value: "https://selfhosted.social", 29 + label: "selfhosted.social", 30 + description: "A popular community-run PDS", 31 + terms: "https://selfhosted.social/legal#terms", 32 + privacy: "https://selfhosted.social/legal", 33 + }, 34 + { 35 + // will redirect to https://bsky.social for sign in :thumb: 36 + value: "https://witchesbutter.us-west.host.bsky.network", 37 + label: "Bluesky", 38 + description: "The main Bluesky PDS instance", 39 + terms: "https://bsky.social/about/support/tos", 40 + privacy: "https://bsky.social/about/support/privacy-policy", 41 + }, 42 + { 43 + value: "https://blacksky.app", 44 + label: "Blacksky PDS", 45 + description: "A PDS service by Blacksky Algorithms", 46 + terms: "https://blackskyweb.xyz/about/support/tos", 47 + privacy: "https://blackskyweb.xyz/about/support/privacy-policy/", 48 + handlePolicyDocs: 49 + "https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services", 50 + }, 51 + { 52 + value: "https://pds.tophhie.cloud", 53 + label: "Tophhie Cloud", 54 + description: "A PDS service by Tophhie", 55 + terms: "https://blog.tophhie.cloud/atproto-tos/", 56 + privacy: "https://blog.tophhie.cloud/atproto-privacy-policy/", 57 + }, 58 + ]; 59 + 60 + // Shuffle the hosts 61 + // items with handle policies should never be first ! 62 + const shuffleArray = <T,>(array: T[]): T[] => { 63 + const arr = [...array]; 64 + for (let i = arr.length - 1; i > 0; i--) { 65 + const j = Math.floor(Math.random() * (i + 1)); 66 + [arr[i], arr[j]] = [arr[j], arr[i]]; 67 + } 68 + return arr; 69 + }; 70 + 71 + const SHUFFLED_PDS_HOSTS = (() => { 72 + const withPolicies = PDS_HOSTS.filter((h) => h.handlePolicyDocs); 73 + const [first, ...withoutPolicies] = PDS_HOSTS.filter( 74 + (h) => !h.handlePolicyDocs, 75 + ); 76 + return [first, ...shuffleArray(withPolicies.concat(withoutPolicies))]; 77 + })(); 78 + 79 + interface PdsHostSelectorModalProps { 80 + open: boolean; 81 + onOpenChange: (open: boolean) => void; 82 + onSubmit: (pdsHost: string) => void; 83 + } 84 + 85 + export const PdsHostSelectorModal: React.FC<PdsHostSelectorModalProps> = ({ 86 + open, 87 + onOpenChange, 88 + onSubmit, 89 + }) => { 90 + const [selectedHost, setSelectedHost] = useState<string | null>( 91 + SHUFFLED_PDS_HOSTS[0].value, 92 + ); 93 + const [customHost, setCustomHost] = useState<string>(""); 94 + const [useCustom, setUseCustom] = useState(false); 95 + const [handlePolicyChecked, hasCheckedHandlePolicy] = useState(false); 96 + 97 + const { theme } = useTheme(); 98 + const { t } = useTranslation(); 99 + 100 + const selectedHostObj = 101 + SHUFFLED_PDS_HOSTS.find((host) => host.value === selectedHost) || 102 + SHUFFLED_PDS_HOSTS[0]; 103 + 104 + const handleCancel = () => { 105 + setSelectedHost(SHUFFLED_PDS_HOSTS[0].value); 106 + setCustomHost(""); 107 + setUseCustom(false); 108 + onOpenChange(false); 109 + }; 110 + 111 + const handleSubmit = () => { 112 + const hostToUse = useCustom ? customHost : selectedHost; 113 + if (!hostToUse) return; 114 + 115 + onSubmit(hostToUse); 116 + handleCancel(); 117 + }; 118 + 119 + const handleLearnMore = () => { 120 + Linking.openURL("https://atproto.com/guides/self-hosting"); 121 + }; 122 + const handleTOS = () => { 123 + Linking.openURL(selectedHostObj.terms); 124 + }; 125 + const handlePrivacy = () => { 126 + Linking.openURL(selectedHostObj.privacy); 127 + }; 128 + 129 + const handleSelectHost = (value: string) => { 130 + setSelectedHost(value); 131 + setUseCustom(false); 132 + }; 133 + 134 + const handleSelectCustom = () => { 135 + setUseCustom(true); 136 + }; 137 + 138 + return ( 139 + <ResponsiveDialog 140 + open={open} 141 + onOpenChange={onOpenChange} 142 + showCloseButton={false} 143 + variant="default" 144 + size="sm" 145 + dismissible={false} 146 + position="center" 147 + > 148 + <View style={[{ maxWidth: 500 }]}> 149 + <View style={[zero.my[4]]}> 150 + <Text size="2xl" style={[zero.mb[2]]}> 151 + {t("pds-selector-title")} 152 + </Text> 153 + <Text style={[{ color: theme.colors.textMuted }]}> 154 + {t("pds-selector-description")} 155 + </Text> 156 + </View> 157 + <View style={[zero.pb[2]]}> 158 + {SHUFFLED_PDS_HOSTS.map((host, index) => ( 159 + <Pressable 160 + key={host.value} 161 + onPress={() => handleSelectHost(host.value)} 162 + style={[ 163 + zero.py[2], 164 + zero.px[3], 165 + zero.r.lg, 166 + { 167 + borderWidth: 1, 168 + borderColor: 169 + !useCustom && selectedHost === host.value 170 + ? theme.colors.primary 171 + : theme.colors.border, 172 + backgroundColor: 173 + !useCustom && selectedHost === host.value 174 + ? "rgba(0, 122, 255, 0.05)" 175 + : "transparent", 176 + }, 177 + index > 0 && zero.mt[2], 178 + ]} 179 + > 180 + <View 181 + style={[ 182 + zero.layout.flex.row, 183 + zero.layout.flex.spaceBetween, 184 + zero.layout.flex.alignCenter, 185 + ]} 186 + > 187 + <View style={[zero.flex[1]]}> 188 + <Text>{host.label}</Text> 189 + <Text 190 + style={[ 191 + zero.mt[1], 192 + { fontSize: 14, color: theme.colors.textMuted }, 193 + ]} 194 + > 195 + {host.description} 196 + </Text> 197 + </View> 198 + {!useCustom && selectedHost === host.value && ( 199 + <Check size={20} color={theme.colors.primary} /> 200 + )} 201 + </View> 202 + </Pressable> 203 + ))} 204 + 205 + <Pressable 206 + onPress={handleSelectCustom} 207 + style={[ 208 + zero.py[2], 209 + zero.px[3], 210 + zero.r.lg, 211 + zero.mt[2], 212 + { 213 + borderWidth: 1, 214 + borderColor: useCustom 215 + ? theme.colors.primary 216 + : theme.colors.border, 217 + backgroundColor: useCustom 218 + ? "rgba(0, 122, 255, 0.05)" 219 + : "transparent", 220 + }, 221 + ]} 222 + > 223 + <View 224 + style={[ 225 + zero.layout.flex.row, 226 + zero.layout.flex.spaceBetween, 227 + zero.layout.flex.alignCenter, 228 + ]} 229 + > 230 + <View style={[zero.flex[1]]}> 231 + <Text>{t("pds-selector-custom-label")}</Text> 232 + <Text 233 + style={[ 234 + zero.mt[1], 235 + { fontSize: 14, color: theme.colors.textMuted }, 236 + ]} 237 + > 238 + {t("pds-selector-custom-description")} 239 + </Text> 240 + </View> 241 + {useCustom && <Check size={20} color={theme.colors.primary} />} 242 + </View> 243 + </Pressable> 244 + 245 + <View style={[zero.mt[4]]}> 246 + <Pressable 247 + onPress={handleLearnMore} 248 + style={[ 249 + zero.layout.flex.row, 250 + zero.gap.all[1], 251 + zero.layout.flex.alignCenter, 252 + ]} 253 + > 254 + <Text style={[{ color: theme.colors.ring, fontSize: 14 }]}> 255 + {t("pds-selector-learn-more")} 256 + </Text> 257 + <ExternalLink size={16} color={theme.colors.ring} /> 258 + </Pressable> 259 + </View> 260 + 261 + {useCustom && ( 262 + <View style={[zero.mt[3]]}> 263 + <Text style={[zero.mb[2], { color: theme.colors.textMuted }]}> 264 + {t("pds-selector-custom-url-label")} 265 + </Text> 266 + <Input 267 + value={customHost} 268 + onChangeText={setCustomHost} 269 + placeholder={t("pds-selector-custom-url-placeholder")} 270 + autoCapitalize="none" 271 + autoCorrect={false} 272 + keyboardType="url" 273 + /> 274 + </View> 275 + )} 276 + <Admonition variant="info" style={[zero.my[4]] as any}> 277 + <Text style={[zero.mb[2]]}>{t("pds-selector-info")}</Text> 278 + {!useCustom && ( 279 + <Text style={[zero.mb[2]]}> 280 + <T 281 + i18nKey="pds-selector-read-policies" 282 + values={{ label: selectedHostObj?.label }} 283 + components={{ 284 + tosLink: ( 285 + <Text 286 + onPress={handleTOS} 287 + style={[{ color: theme.colors.ring }]} 288 + /> 289 + ), 290 + privacyLink: ( 291 + <Text 292 + onPress={handlePrivacy} 293 + style={[{ color: theme.colors.ring }]} 294 + /> 295 + ), 296 + }} 297 + /> 298 + </Text> 299 + )} 300 + </Admonition> 301 + {!useCustom && selectedHostObj.handlePolicyDocs && ( 302 + <View 303 + style={[ 304 + zero.layout.flex.row, 305 + zero.layout.flex.align.center, 306 + zero.layout.flex.justify.start, 307 + zero.gap.all[2], 308 + zero.mb[4], 309 + zero.mt[2], 310 + ]} 311 + > 312 + <Checkbox 313 + checked={handlePolicyChecked} 314 + onCheckedChange={hasCheckedHandlePolicy} 315 + /> 316 + <Text style={[zero.flex[1]]}> 317 + <T 318 + i18nKey="pds-selector-handle-policy-checkbox" 319 + components={{ 320 + policyLink: ( 321 + <Text 322 + onPress={() => 323 + Linking.openURL(selectedHostObj.handlePolicyDocs!) 324 + } 325 + style={[{ color: theme.colors.ring }]} 326 + > 327 + {selectedHostObj.label} guidelines and handle policy 328 + </Text> 329 + ), 330 + }} 331 + /> 332 + </Text> 333 + </View> 334 + )} 335 + </View> 336 + <View 337 + style={[ 338 + zero.flex[1], 339 + zero.layout.flex.row, 340 + zero.layout.flex.justify.end, 341 + zero.gap.all[2], 342 + ]} 343 + > 344 + <Button width="min" variant="secondary" onPress={handleCancel}> 345 + <Text>{t("cancel")}</Text> 346 + </Button> 347 + <Button 348 + width="min" 349 + variant="primary" 350 + onPress={handleSubmit} 351 + disabled={ 352 + (useCustom && !customHost.trim()) || 353 + (!handlePolicyChecked && !!selectedHostObj.handlePolicyDocs) 354 + } 355 + > 356 + <Text>{t("continue")}</Text> 357 + </Button> 358 + </View> 359 + </View> 360 + </ResponsiveDialog> 361 + ); 362 + }; 363 + 364 + export default PdsHostSelectorModal;
+1
js/app/components/mobile/desktop-ui.tsx
··· 254 254 setTitle={setTitle} 255 255 ingestStarting={ingestStarting} 256 256 toggleGoLive={toggleGoLive} 257 + isLive={isActivelyLive} 257 258 /> 258 259 )} 259 260
+16 -3
js/app/components/mobile/ui.tsx
··· 72 72 ingestStarting, 73 73 setIngestStarting, 74 74 toggleGoLive, 75 + toggleStopStream, 75 76 } = useLivestreamInfo(); 76 77 const { width, height } = usePlayerDimensions(); 77 78 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 102 103 103 104 const isSelfAndNotLive = ingest === "new"; 104 105 const isLive = ingest !== null && ingest !== "new"; 106 + 107 + useEffect(() => { 108 + if (isLive && ingestStarting) { 109 + setIngestStarting(false); 110 + } 111 + }, [isLive, ingestStarting, setIngestStarting]); 105 112 106 113 const FADE_OUT_DELAY = 4000; 107 114 const fadeOpacity = useSharedValue(1); ··· 222 229 <View 223 230 style={[ 224 231 layout.position.absolute, 225 - position.top[28], 232 + position.top[32], 226 233 position.left[0], 227 234 position.right[0], 228 235 layout.flex.column, ··· 230 237 ]} 231 238 > 232 239 <PlayerUI.MetricsPanel 233 - showMetrics={isLive || isSelfAndNotLive} 240 + showMetrics={shouldShowFloatingMetrics} 234 241 /> 235 242 </View> 236 243 )} ··· 241 248 setTitle={setTitle} 242 249 ingestStarting={ingestStarting} 243 250 toggleGoLive={toggleGoLive} 251 + isLive={isLive} 244 252 /> 245 253 )} 246 254 ··· 468 476 <Pressable onPress={doSetIngestCamera}> 469 477 <SwitchCamera color={theme.colors.foreground} size={20} /> 470 478 </Pressable> 479 + {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} 471 480 </> 472 481 )} 473 482 {Platform.OS === "web" ? ( ··· 515 524 )} 516 525 </Pressable> 517 526 )} 518 - <PlayerUI.ContextMenu /> 527 + {ingest === null ? ( 528 + <PlayerUI.ContextMenu /> 529 + ) : ( 530 + <PlayerUI.StreamContextMenu /> 531 + )} 519 532 </View> 520 533 )} 521 534 {shouldShowChatSidePanel && setShowChat && (
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 2 import { storage } from "@streamplace/components"; 3 3 import { useURL } from "expo-linking"; 4 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 5 6 import { useStore } from "store"; 6 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 8 import { navigateToRoute } from "utils/navigation"; ··· 23 24 loadOAuthClient(); 24 25 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 26 30 storage.getItem("returnRoute").then((stored) => { 27 31 if (stored) { 28 32 try { ··· 82 86 if ( 83 87 lastAuthStatus !== "loggedIn" && 84 88 authStatus === "loggedIn" && 85 - returnRoute 89 + returnRoute && 90 + Platform.OS === "web" 86 91 ) { 87 92 console.log( 88 93 "Login successful, navigating back to returnRoute:",
+8 -3
js/app/hooks/useBlueskyNotifications.tsx
··· 1 1 import { useToast } from "@streamplace/components"; 2 2 import { CircleX } from "lucide-react-native"; 3 3 import { useEffect } from "react"; 4 + import { Platform } from "react-native"; 5 + import clearQueryParams from "utils/clear-query-params"; 4 6 import { useStore } from "../store"; 5 7 6 8 function titleCase(str: string) { ··· 18 20 let toast = useToast(); 19 21 const notification = useStore((state) => state.notification); 20 22 const clearNotification = useStore((state) => state.clearNotification); 23 + 24 + // we've already saved the notif to the store 25 + clearQueryParams(["error", "error_description"]); 21 26 22 27 useEffect(() => { 23 28 if (notification) { ··· 41 46 { 42 47 duration: 100, 43 48 variant: notification.type, 44 - actionLabel: "Copy message", 49 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 45 50 iconLeft: CircleX, 46 51 onAction: () => { 47 52 navigator.clipboard.writeText( ··· 59 64 notification.message, 60 65 { 61 66 variant: notification.type, 62 - actionLabel: "Copy message", 67 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 63 68 onAction: () => { 64 69 navigator.clipboard.writeText(notification.message); 65 70 }, ··· 74 79 notification.message, 75 80 { 76 81 variant: notification.type, 77 - actionLabel: "Copy message", 82 + actionLabel: Platform.OS === "web" ? "Copy message" : undefined, 78 83 onAction: () => { 79 84 navigator.clipboard.writeText(notification.message); 80 85 },
+1 -1
js/app/package.json
··· 1 1 { 2 2 "name": "@streamplace/app", 3 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.9.5", 4 + "version": "0.9.9", 5 5 "runtimeVersion": "0.7.2", 6 6 "scripts": { 7 7 "start": "npx expo start -c --port 38081",
+21 -7
js/app/src/router.tsx
··· 81 81 import HomeScreen from "./screens/home"; 82 82 83 83 import { useUrl } from "@streamplace/components"; 84 + import PdsHostSelectorModal from "components/login/pds-host-selector-modal"; 84 85 import { BrandingAdmin } from "components/settings/branding-admin"; 85 86 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 86 87 import MultistreamManager from "components/settings/multistream-manager"; ··· 297 298 const AvatarButton = () => { 298 299 const userProfile = useUserProfile(); 299 300 const openLoginModal = useStore((state) => state.openLoginModal); 301 + const openPDSModal = useStore((state) => state.openPdsModal); 300 302 const loginAction = useStore((state) => state.login); 301 303 const openLoginLink = useStore((state) => state.openLoginLink); 302 304 const { theme } = useTheme(); ··· 332 334 ); 333 335 } 334 336 335 - const handleSignup = () => { 336 - // TODO: remove requirement for oauth-protected-resource in oatproxy 337 - loginAction("https://bsky.social", openLoginLink); 338 - }; 339 - 340 337 if (isCompact) { 341 338 return ( 342 339 <Button ··· 369 366 <Text style={{ color: theme.colors.text }}>Log In</Text> 370 367 </Button> 371 368 <Button 372 - onPress={handleSignup} 369 + onPress={() => openPDSModal()} 373 370 variant="primary" 374 371 width="min" 375 372 style={[zero.r.full]} ··· 477 474 const pollMySegments = useStore((state) => state.pollMySegments); 478 475 const showLoginModal = useStore((state) => state.showLoginModal); 479 476 const closeLoginModal = useStore((state) => state.closeLoginModal); 477 + const showPdsModal = useStore((state) => state.showPdsModal); 478 + const openPdsModal = useStore((state) => state.openPdsModal); 479 + const closePdsModal = useStore((state) => state.closePdsModal); 480 480 const [livePopup, setLivePopup] = useState(false); 481 + const loginAction = useStore((state) => state.login); 482 + const openLoginLink = useStore((state) => state.openLoginLink); 481 483 const siteTitle = useSiteTitle(); 482 484 const defaultStreamer = useDefaultStreamer(); 483 485 ··· 784 786 }} 785 787 /> 786 788 </Drawer.Navigator> 787 - <LoginModal visible={showLoginModal} onClose={closeLoginModal} /> 789 + <LoginModal 790 + visible={showLoginModal} 791 + onClose={closeLoginModal} 792 + onOpenPdsModal={openPdsModal} 793 + /> 794 + <PdsHostSelectorModal 795 + open={showPdsModal} 796 + onOpenChange={closePdsModal} 797 + onSubmit={(pdsHost) => { 798 + closePdsModal(); 799 + loginAction(pdsHost, openLoginLink); 800 + }} 801 + /> 788 802 </> 789 803 ); 790 804 }
+14 -16
js/app/store/slices/blueskySlice.ts
··· 19 19 PlaceStreamServerSettings, 20 20 StreamplaceAgent, 21 21 } from "streamplace"; 22 + import clearQueryParams from "utils/clear-query-params"; 22 23 import { privateKeyToAccount } from "viem/accounts"; 23 24 import { StateCreator } from "zustand"; 24 25 import createOAuthClient, { ··· 86 87 showLoginModal: boolean; 87 88 openLoginModal: (returnRoute?: { name: string; params?: any }) => void; 88 89 closeLoginModal: () => void; 90 + showPdsModal: boolean; 91 + openPdsModal: () => void; 92 + closePdsModal: () => void; 89 93 golivePost: ( 90 94 text: string, 91 95 now: Date, ··· 114 118 createServerSettingsRecord: (debugRecording: boolean) => Promise<void>; 115 119 } 116 120 117 - const clearQueryParams = () => { 118 - if (Platform.OS !== "web") { 119 - return; 120 - } 121 - const u = new URL(document.location.href); 122 - const params = new URLSearchParams(u.search); 123 - if (u.search === "") { 124 - return; 125 - } 126 - params.delete("iss"); 127 - params.delete("state"); 128 - params.delete("code"); 129 - u.search = params.toString(); 130 - window.history.replaceState(null, "", u.toString()); 131 - }; 132 - 133 121 const uploadThumbnail = async ( 134 122 handle: string, 135 123 u: URL, ··· 210 198 serverSettings: null, 211 199 returnRoute: null, 212 200 showLoginModal: false, 201 + showPdsModal: false, 213 202 notification: null, 214 203 215 204 clearNotification: () => { 205 + clearQueryParams(); 216 206 set({ notification: null }); 217 207 }, 218 208 ··· 237 227 closeLoginModal: () => { 238 228 console.log("closeLoginModal"); 239 229 set({ showLoginModal: false }); 230 + }, 231 + 232 + openPdsModal: () => { 233 + set({ showPdsModal: true }); 234 + }, 235 + 236 + closePdsModal: () => { 237 + set({ showPdsModal: false }); 240 238 }, 241 239 242 240 loadOAuthClient: async () => {
+11 -15
js/app/store/slices/contentMetadataSlice.ts
··· 1 + import { 2 + getPDSServiceEndpoint, 3 + resolveDIDDocument, 4 + } from "@streamplace/components"; 1 5 import { AppStore } from "store"; 2 6 import { StateCreator } from "zustand"; 3 7 import { BlueskySlice } from "./blueskySlice"; ··· 201 205 }); 202 206 203 207 try { 204 - let targetPDS = null; 208 + let targetPDS: string | null = null; 205 209 try { 206 - const didResponse = await fetch(`https://plc.directory/${targetDid}`); 207 - if (didResponse.ok) { 208 - const didDoc = await didResponse.json(); 209 - const pdsService = didDoc.service?.find( 210 - (s: any) => s.id === "#atproto_pds", 211 - ); 212 - if (pdsService) { 213 - targetPDS = pdsService.serviceEndpoint; 214 - console.log( 215 - `[getContentMetadata] Resolved PDS for ${targetDid}:`, 216 - targetPDS, 217 - ); 218 - } 219 - } 210 + const didDoc = await resolveDIDDocument(targetDid); 211 + targetPDS = getPDSServiceEndpoint(didDoc); 212 + console.log( 213 + `[getContentMetadata] Resolved PDS for ${targetDid}:`, 214 + targetPDS, 215 + ); 220 216 } catch (pdsResolveError) { 221 217 console.log( 222 218 `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`,
+15
js/app/utils/clear-query-params.ts
··· 1 + import { Platform } from "react-native"; 2 + 3 + export default function clearQueryParams(par = ["iss", "state", "code"]) { 4 + if (Platform.OS !== "web") { 5 + return; 6 + } 7 + const u = new URL(document.location.href); 8 + const params = new URLSearchParams(u.search); 9 + if (u.search === "") { 10 + return; 11 + } 12 + par.forEach((p) => params.delete(p)); 13 + u.search = params.toString(); 14 + window.history.replaceState(null, "", u.toString()); 15 + }
+1 -1
js/atproto-oauth-client-react-native/package.json
··· 1 1 { 2 2 "name": "@streamplace/atproto-oauth-client-react-native", 3 - "version": "0.9.0", 3 + "version": "0.9.9", 4 4 "license": "MIT", 5 5 "description": "ATProto OAuth client for React Native", 6 6 "keywords": [
js/components/assets/badges/live.png

This is a binary file and will not be displayed.

js/components/assets/badges/mod.png

This is a binary file and will not be displayed.

js/components/assets/badges/vip.png

This is a binary file and will not be displayed.

+13 -1
js/components/locales/en-US/common.ftl
··· 51 51 [streamer] Looks like <1>@{ $handle } is offline</1>, but they recommend checking out: 52 52 *[default] Looks like <1>@{ $handle } is offline</1>, but we recommend checking out: 53 53 } 54 - user-offline-no-recommendations = 54 + user-offline-no-recommendations = 55 55 Looks like <1>@{ $handle } is offline</1> right now. 56 56 Check back later. 57 57 streaming-title = streaming { $title } ··· 60 60 [1] 1 viewer 61 61 *[other] { $count } viewers 62 62 } 63 + 64 + ## PDS Host Selector 65 + pds-selector-title = New to the Atmosphere? 66 + pds-selector-description = You'll need to select a PDS (Personal Data Server) to access apps on the Atmosphere, such as Bluesky, Tangled, and Spark. 67 + pds-selector-custom-label = Another PDS 68 + pds-selector-custom-description = Enter your own PDS host URL 69 + pds-selector-custom-url-label = Custom PDS URL 70 + pds-selector-custom-url-placeholder = https://pds.example.com 71 + pds-selector-learn-more = Learn more about self-hosting 72 + pds-selector-info = Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose. 73 + pds-selector-read-policies = Read { $label }'s <tosLink>Terms of Service</tosLink> and <privacyLink>Privacy Policy</privacyLink> before continuing. 74 + pds-selector-handle-policy-checkbox = I have read and agree to the <policyLink>handle policy</policyLink>
+2 -1
js/components/package.json
··· 1 1 { 2 2 "name": "@streamplace/components", 3 - "version": "0.9.4", 3 + "version": "0.9.9", 4 4 "description": "Streamplace React (Native) Components", 5 5 "main": "dist/index.js", 6 6 "types": "src/index.tsx", ··· 42 42 "expo-sensors": "^15.0.7", 43 43 "expo-sqlite": "~15.2.12", 44 44 "expo-video": "^2.0.0", 45 + "graphemer": "^1.4.0", 45 46 "hls.js": "^1.5.17", 46 47 "i18next": "^25.4.2", 47 48 "i18next-browser-languagedetector": "^8.2.0",
+24 -2
js/components/src/components/chat/chat-box.tsx
··· 1 1 import Picker from "@emoji-mart/react"; 2 + import Graphemer from "graphemer"; 2 3 import { AtSignIcon, ExternalLink, X } from "lucide-react-native"; 3 4 import { env } from "process"; 4 5 import { useEffect, useMemo, useRef, useState } from "react"; 5 6 import { Platform, Pressable, TextInput } from "react-native"; 6 7 import { ChatMessageViewHydrated } from "streamplace"; 7 - import { Button, Loader, Text, useTheme, View } from "../../"; 8 + import { Button, Loader, Text, toast, useTheme, View } from "../../"; 8 9 import { handleSlashCommand } from "../../lib/slash-commands"; 9 10 import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 10 11 import { StreamNotifications } from "../../lib/stream-notifications"; ··· 41 42 ..."๐Ÿ˜€๐Ÿฅธ๐Ÿ˜๐Ÿ˜˜๐Ÿ˜๐Ÿฅธ๐Ÿ˜†๐Ÿฅธ๐Ÿ˜œ๐Ÿฅธ๐Ÿ˜‚๐Ÿ˜…๐Ÿฅธ๐Ÿ™‚๐Ÿคซ๐Ÿ˜ฑ๐Ÿฅธ๐Ÿคฃ๐Ÿ˜—๐Ÿ˜„๐Ÿฅธ๐Ÿ˜Ž๐Ÿค“๐Ÿ˜ฒ๐Ÿ˜ฏ๐Ÿ˜ฐ๐Ÿฅธ๐Ÿ˜ฅ๐Ÿฅธ๐Ÿ˜ฃ๐Ÿฅธ๐Ÿ˜ž๐Ÿ˜“๐Ÿฅธ๐Ÿ˜ฉ๐Ÿ˜ฉ๐Ÿฅธ๐Ÿ˜ค๐Ÿฅฑ", 42 43 ]; 43 44 45 + const graphemer = new Graphemer(); 46 + 44 47 export function ChatBox({ 45 48 isPopout, 46 49 chatBoxStyle, ··· 65 68 new Map(), 66 69 ); 67 70 const [filteredEmojis, setFilteredEmojis] = useState<any[]>([]); 71 + const isOverLimit = graphemer.countGraphemes(message) > 300; 68 72 69 73 let linfo = useLivestream(); 70 74 ··· 255 259 256 260 const submit = async () => { 257 261 if (!message.trim()) return; 262 + if (graphemer.countGraphemes(message) > 300) { 263 + toast.show( 264 + "Message too long", 265 + "Please limit your message to 300 characters.", 266 + { 267 + variant: "error", 268 + duration: 3, 269 + }, 270 + ); 271 + return; 272 + } 258 273 259 274 const messageText = message; 260 275 setMessage(""); ··· 457 472 } 458 473 } 459 474 }} 460 - style={[chatBoxStyle]} 475 + style={[ 476 + chatBoxStyle, 477 + isOverLimit && { 478 + borderColor: "#ef4444", 479 + borderWidth: 2, 480 + outline: "none", 481 + }, 482 + ]} 461 483 // "submit" won't blur on enter 462 484 submitBehavior="submit" 463 485 placeholder="Type a message..."
+27 -1
js/components/src/components/chat/chat-message.tsx
··· 4 4 Mention, 5 5 } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 6 6 import { memo, useCallback } from "react"; 7 - import { Linking, View } from "react-native"; 7 + import { Image, Linking, View } from "react-native"; 8 8 import { ChatMessageViewHydrated } from "streamplace"; 9 9 import { RichtextSegment, segmentize } from "../../lib/facet"; 10 10 import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; ··· 23 23 }>; 24 24 } 25 25 26 + import { zero } from "../.."; 26 27 import { useLivestreamStore } from "../../livestream-store"; 27 28 import { Text } from "../ui/text"; 28 29 ··· 164 165 style={{ 165 166 fontVariant: ["tabular-nums"], 166 167 color: colors.gray[400], 168 + width: 44, 167 169 }} 168 170 > 169 171 {formatTime(item.record.createdAt)} 170 172 </Text> 171 173 )} 174 + {item.badges?.length ? ( 175 + <View style={[zero.layout.flex.align.end]}> 176 + {item.badges.map((badge, index) => ( 177 + <View style={{ height: 3 }} key={`badge-${index}`}> 178 + {badge.badgeType === "place.stream.badge.defs#mod" ? ( 179 + <Image 180 + source={require("../../../assets/badges/mod.png")} 181 + style={{ height: 20, width: 20, marginTop: 3 }} 182 + /> 183 + ) : badge.badgeType === "place.stream.badge.defs#streamer" ? ( 184 + <Image 185 + source={require("../../../assets/badges/live.png")} 186 + style={{ height: 20, width: 20, marginTop: 3 }} 187 + /> 188 + ) : ( 189 + <Image 190 + source={require("../../../assets/badges/vip.png")} 191 + style={{ height: 20, width: 20, marginTop: 3 }} 192 + /> 193 + )} 194 + </View> 195 + ))} 196 + </View> 197 + ) : null} 172 198 <Text 173 199 weight="bold" 174 200 color="default"
+2 -2
js/components/src/components/chat/chat.tsx
··· 261 261 262 262 useEffect(() => { 263 263 buttonOpacity.value = withTiming(isScrolledUp ? 1 : 0, { duration: 200 }); 264 - buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 20, { 264 + buttonTranslateY.value = withTiming(isScrolledUp ? 0 : 50, { 265 265 duration: 200, 266 266 }); 267 267 }, [isScrolledUp]); ··· 345 345 onPress={scrollToBottom} 346 346 style={[ 347 347 { 348 - pointerEvents: "auto", 348 + pointerEvents: isScrolledUp ? "auto" : "none", 349 349 backgroundColor: theme.colors.primary, 350 350 opacity: 0.9, 351 351 borderRadius: 20,
+1 -14
js/components/src/components/dashboard/header.tsx
··· 1 - import { AlertCircle, Car, Radio, Users } from "lucide-react-native"; 1 + import { AlertCircle, Radio } from "lucide-react-native"; 2 2 import { Pressable, Text, View } from "react-native"; 3 3 import * as zero from "../../ui"; 4 4 ··· 98 98 interface HeaderProps { 99 99 isLive: boolean; 100 100 streamTitle?: string; 101 - viewers?: number; 102 101 uptime?: string; 103 102 bitrate?: string; 104 103 timeBetweenSegments?: number; ··· 110 109 export default function Header({ 111 110 isLive, 112 111 streamTitle = "Live Stream", 113 - viewers = 0, 114 112 uptime = "00:00:00", 115 113 bitrate = "0 mbps", 116 114 timeBetweenSegments = 0, ··· 179 177 180 178 {/* Right side - Stream metrics */} 181 179 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[6]]}> 182 - {isLive && ( 183 - <> 184 - <MetricItem 185 - icon={Users} 186 - label="Viewers" 187 - value={viewers.toLocaleString()} 188 - /> 189 - <MetricItem icon={Car} label="Bitrate" value={bitrate} /> 190 - </> 191 - )} 192 - 193 180 {!isLive && ( 194 181 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 195 182 <Radio size={16} color="#6b7280" />
+2 -1
js/components/src/components/dashboard/information-widget.tsx
··· 12 12 import React, { useCallback, useEffect, useMemo, useState } from "react"; 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 + import { useAQState } from "../../hooks"; 15 16 import { 16 17 useLivestreamStore, 17 18 useSegment, ··· 38 39 const [bitrateHistory, setBitrateHistory] = useState<number[]>( 39 40 Array.from({ length: BITRATE_HISTORY_LENGTH }, () => 0), 40 41 ); 41 - const [showViewers, setShowViewers] = useState(false); 42 + const [showViewers, setShowViewers] = useAQState("showViewers", true); 42 43 const [componentWidth, setComponentWidth] = useState<number>(220); 43 44 const [componentHeight, setComponentHeight] = useState<number>(400); 44 45 const [streamStartTime, setStreamStartTime] = useState<Date | null>(null);
+22 -1
js/components/src/components/mobile-player/player.tsx
··· 5 5 PlayerStatusTracker, 6 6 usePlayerStore, 7 7 } from "../../player-store"; 8 - import { useStreamplaceStore } from "../../streamplace-store"; 8 + import { 9 + useMuted, 10 + useSetMuted, 11 + useStreamplaceStore, 12 + } from "../../streamplace-store"; 9 13 import { Text, View } from "../ui"; 10 14 import { Fullscreen } from "./fullscreen"; 11 15 import { PlayerProps } from "./props"; ··· 28 32 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 29 33 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 30 34 const reportSubject = usePlayerStore((x) => x.reportSubject); 35 + 36 + const setMuted = useSetMuted(); 37 + const muted = useMuted(); 38 + 39 + // if we set muted, set it and restore after 40 + useEffect(() => { 41 + let wasMuted: null | boolean = null; 42 + setTimeout(() => { 43 + if (props.muted != undefined) { 44 + wasMuted = muted; 45 + setMuted(props.muted); 46 + } 47 + }, 200); 48 + return () => { 49 + wasMuted !== null && setMuted(wasMuted); 50 + }; 51 + }, [props.muted]); 31 52 32 53 useEffect(() => { 33 54 setReportingURL(props.reportingURL ?? null);
+42 -8
js/components/src/components/mobile-player/ui/input.tsx
··· 9 9 setTitle: (title: string) => void; 10 10 ingestStarting: boolean; 11 11 toggleGoLive: () => void; 12 + isLive: boolean; 13 + toggleStopStream?: () => void; 12 14 }; 13 15 14 16 export function InputPanel({ ··· 16 18 setTitle, 17 19 ingestStarting, 18 20 toggleGoLive, 21 + isLive, 22 + toggleStopStream, 19 23 }: InputPanelProps) { 20 24 const { slideKeyboard } = useKeyboardSlide(); 21 25 return ( ··· 37 41 { padding: 10 }, 38 42 ]} 39 43 > 40 - <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 - <Input 42 - value={title} 43 - onChange={setTitle} 44 - placeholder="Enter stream title" 45 - onEndEditing={Keyboard.dismiss} 46 - /> 47 - </View> 44 + {!isLive && ( 45 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 46 + <Input 47 + value={title} 48 + onChange={setTitle} 49 + placeholder="Enter stream title" 50 + onEndEditing={Keyboard.dismiss} 51 + /> 52 + </View> 53 + )} 48 54 {ingestStarting ? ( 49 55 <Text>Starting your stream...</Text> 56 + ) : isLive ? ( 57 + <View style={[layout.flex.center]}> 58 + <Pressable 59 + onPress={toggleStopStream} 60 + style={[ 61 + px[4], 62 + py[2], 63 + layout.flex.row, 64 + layout.flex.center, 65 + gap.all[1], 66 + { 67 + backgroundColor: "rgba(64,64,64, 0.8)", 68 + borderRadius: 12, 69 + }, 70 + ]} 71 + > 72 + <View 73 + style={[ 74 + p[2], 75 + { 76 + backgroundColor: "rgba(256,0,0, 0.8)", 77 + borderRadius: 12, 78 + }, 79 + ]} 80 + /> 81 + <Text center>Stop Stream</Text> 82 + </Pressable> 83 + </View> 50 84 ) : ( 51 85 <View style={[layout.flex.center]}> 52 86 <Pressable
+138 -2
js/components/src/components/mobile-player/ui/streamer-context-menu.tsx
··· 1 - export function StreamContextMenu() { 2 - return <></>; 1 + import { ChevronRight, Cog } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withDelay, 8 + withSequence, 9 + withTiming, 10 + } from "react-native-reanimated"; 11 + import { useLivestreamInfo, zero } from "../../.."; 12 + import { usePlayerStore } from "../../../player-store"; 13 + import { 14 + DropdownMenu, 15 + DropdownMenuCheckboxItem, 16 + DropdownMenuGroup, 17 + DropdownMenuItem, 18 + DropdownMenuTrigger, 19 + ResponsiveDropdownMenuContent, 20 + Text, 21 + useTheme, 22 + } from "../../ui"; 23 + 24 + export function StreamContextMenu({ 25 + dropdownPortalContainer, 26 + }: { 27 + dropdownPortalContainer?: string; 28 + }) { 29 + const th = useTheme(); 30 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 31 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 32 + const { toggleStopStream } = useLivestreamInfo(); 33 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 34 + const isLive = ingest !== null && ingest !== "new"; 35 + 36 + const [isOpen, setIsOpen] = useState(false); 37 + const [hasShownTooltip, setHasShownTooltip] = useState(false); 38 + 39 + const tooltipOpacity = useSharedValue(0); 40 + const tooltipTranslateX = useSharedValue(20); 41 + 42 + useEffect(() => { 43 + if (isLive && !hasShownTooltip) { 44 + tooltipOpacity.value = withDelay( 45 + 500, 46 + withSequence( 47 + withTiming(1, { duration: 300 }), 48 + withDelay(10000, withTiming(0, { duration: 300 })), 49 + ), 50 + ); 51 + tooltipTranslateX.value = withDelay( 52 + 500, 53 + withSequence( 54 + withTiming(0, { duration: 300 }), 55 + withDelay(10000, withTiming(20, { duration: 300 })), 56 + ), 57 + ); 58 + setHasShownTooltip(true); 59 + } 60 + }, [isLive, hasShownTooltip]); 61 + 62 + const iconRotate = useAnimatedStyle(() => { 63 + return { 64 + transform: [ 65 + { 66 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 67 + duration: 650, 68 + easing: Easing.out(Easing.ease), 69 + }), 70 + }, 71 + ], 72 + }; 73 + }); 74 + 75 + const tooltipStyle = useAnimatedStyle(() => { 76 + return { 77 + opacity: tooltipOpacity.value, 78 + transform: [{ translateX: tooltipTranslateX.value }], 79 + }; 80 + }); 81 + 82 + return ( 83 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 84 + <DropdownMenuTrigger> 85 + <Animated.View style={[iconRotate]}> 86 + <Cog color={th.theme.colors.foreground} /> 87 + </Animated.View> 88 + <Animated.View 89 + style={[ 90 + tooltipStyle, 91 + { 92 + position: "absolute", 93 + right: 30, 94 + top: 0, 95 + backgroundColor: "rgba(64,64,64,0.95)", 96 + borderRadius: 8, 97 + paddingHorizontal: 8, 98 + paddingRight: 12, 99 + paddingVertical: 4, 100 + flexDirection: "row", 101 + alignItems: "center", 102 + gap: 6, 103 + zIndex: 9999999, 104 + pointerEvents: "box-none", 105 + width: 120, 106 + }, 107 + ]} 108 + > 109 + <Text size="sm" color="white"> 110 + End stream here 111 + </Text> 112 + <ChevronRight color="white" size={16} style={[zero.mr[4]]} /> 113 + </Animated.View> 114 + </DropdownMenuTrigger> 115 + <ResponsiveDropdownMenuContent side="top" align="end"> 116 + {isLive && ( 117 + <DropdownMenuGroup title="Stream"> 118 + <DropdownMenuItem 119 + closeOnPress={true} 120 + onPress={() => { 121 + toggleStopStream(); 122 + }} 123 + > 124 + <Text color="destructive">Stop Stream</Text> 125 + </DropdownMenuItem> 126 + </DropdownMenuGroup> 127 + )} 128 + <DropdownMenuGroup title="Advanced"> 129 + <DropdownMenuCheckboxItem 130 + checked={debugInfo} 131 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 132 + > 133 + <Text>Show Debug Info</Text> 134 + </DropdownMenuCheckboxItem> 135 + </DropdownMenuGroup> 136 + </ResponsiveDropdownMenuContent> 137 + </DropdownMenu> 138 + ); 3 139 }
+3
js/components/src/components/ui/button.tsx
··· 41 41 loading?: boolean; 42 42 loadingText?: string; 43 43 width?: "full" | "min" | number; 44 + hoverStyle?: ButtonPrimitiveProps["hoverStyle"]; 44 45 } 45 46 46 47 export const Button = forwardRef<any, ButtonProps>( ··· 56 57 disabled, 57 58 style, 58 59 width = "full", 60 + hoverStyle, 59 61 ...props 60 62 }, 61 63 ref, ··· 222 224 ref={ref} 223 225 disabled={disabled || loading} 224 226 style={[buttonStyle, sizeStyles.button, widthStyle, style]} 227 + hoverStyle={hoverStyle} 225 228 {...props} 226 229 > 227 230 <ButtonPrimitive.Content style={sizeStyles.inner}>
+37 -11
js/components/src/components/ui/primitives/button.tsx
··· 1 - import React, { forwardRef } from "react"; 1 + import React, { forwardRef, useState } from "react"; 2 2 import { 3 3 AccessibilityRole, 4 4 GestureResponderEvent, 5 + Platform, 6 + Pressable, 7 + PressableProps, 8 + StyleProp, 5 9 StyleSheet, 6 10 Text, 7 11 TextProps, 8 - TouchableOpacity, 9 - TouchableOpacityProps, 10 12 View, 11 13 ViewProps, 14 + ViewStyle, 12 15 } from "react-native"; 13 16 14 17 // Base button primitive interface 15 - export interface ButtonPrimitiveProps 16 - extends Omit<TouchableOpacityProps, "onPress"> { 18 + export interface ButtonPrimitiveProps extends Omit<PressableProps, "onPress"> { 17 19 onPress?: (event: GestureResponderEvent) => void; 18 20 disabled?: boolean; 19 21 loading?: boolean; ··· 21 23 accessibilityLabel?: string; 22 24 accessibilityHint?: string; 23 25 testID?: string; 26 + hoverStyle?: StyleProp<ViewStyle>; 24 27 } 25 28 26 29 // Button root primitive - handles all touch interactions 27 30 export const ButtonRoot = forwardRef< 28 - React.ComponentRef<typeof TouchableOpacity>, 31 + React.ComponentRef<typeof Pressable>, 29 32 ButtonPrimitiveProps 30 33 >( 31 34 ( ··· 43 46 accessibilityState, 44 47 testID, 45 48 style, 46 - activeOpacity = 0.7, 49 + hoverStyle, 47 50 ...props 48 51 }, 49 52 ref, 50 53 ) => { 54 + const [isHovered, setIsHovered] = useState(false); 55 + 51 56 const handlePress = React.useCallback( 52 57 (event: GestureResponderEvent) => { 53 58 if (!disabled && !loading && onPress) { ··· 84 89 [disabled, loading, onLongPress], 85 90 ); 86 91 92 + const handleHoverIn = React.useCallback(() => { 93 + if (!disabled && !loading) { 94 + setIsHovered(true); 95 + } 96 + }, [disabled, loading]); 97 + 98 + const handleHoverOut = React.useCallback(() => { 99 + setIsHovered(false); 100 + }, []); 101 + 87 102 return ( 88 - <TouchableOpacity 103 + <Pressable 89 104 ref={ref} 90 105 onPress={handlePress} 91 106 onPressIn={handlePressIn} 92 107 onPressOut={handlePressOut} 93 108 onLongPress={handleLongPress} 109 + onHoverIn={handleHoverIn} 110 + onHoverOut={handleHoverOut} 94 111 disabled={disabled || loading} 95 - activeOpacity={disabled || loading ? 1 : activeOpacity} 96 112 accessibilityRole={accessibilityRole} 97 113 accessibilityLabel={accessibilityLabel} 98 114 accessibilityHint={accessibilityHint} ··· 104 120 testID={testID} 105 121 style={[ 106 122 primitiveStyles.button, 123 + primitiveStyles.transition, 107 124 (disabled || loading) && primitiveStyles.disabled, 108 - style, 125 + style as any, 126 + isHovered && hoverStyle, 109 127 ]} 110 128 {...props} 111 129 > 112 130 {children} 113 - </TouchableOpacity> 131 + </Pressable> 114 132 ); 115 133 }, 116 134 ); ··· 245 263 alignItems: "center", 246 264 justifyContent: "center", 247 265 }, 266 + transition: 267 + Platform.OS === "web" 268 + ? // probably fine if web-only 269 + ({ 270 + transitionDuration: "150ms", 271 + transitionProperty: "background-color, border-color, color", 272 + } as any) 273 + : undefined, 248 274 disabled: { 249 275 opacity: 0.5, 250 276 },
+2 -1
js/components/src/components/ui/resizeable.tsx
··· 95 95 translateY: 96 96 slideKeyboard + 97 97 Math.max(0, -sheetHeight.value) + 98 - (slideKeyboard < 0 ? 0 : -safeBottom), 98 + (slideKeyboard < 0 ? 0 : -safeBottom) - 99 + (Math.abs(slideKeyboard) > 1 ? 32 : 16), 99 100 }, 100 101 ], 101 102 }));
+5
js/components/src/components/ui/textarea.tsx
··· 5 5 import * as React from "react"; 6 6 import { Platform, TextInput, type TextInputProps } from "react-native"; 7 7 import { bg, borders, flex, p, text } from "../../lib/theme/atoms"; 8 + import { useTheme } from "../../ui"; 8 9 9 10 const Textarea = React.forwardRef<TextInput, TextInputProps>( 10 11 ({ style, multiline = true, numberOfLines = 4, ...props }, ref) => { 12 + let th = useTheme(); 11 13 // Detect if we're inside a bottom sheet 12 14 let isInBottomSheet = false; 13 15 try { ··· 38 40 { borderRadius: 10 }, 39 41 style, 40 42 ]} 43 + autoComplete={props.autoComplete || "off"} 44 + textContentType={props.textContentType || "none"} 41 45 multiline={multiline} 42 46 numberOfLines={numberOfLines} 43 47 textAlignVertical="top" 48 + placeholderTextColor={th.theme.colors.textMuted} 44 49 {...props} 45 50 /> 46 51 );
+1
js/components/src/hooks/index.ts
··· 1 1 // barrel file :) 2 + export * from "./useAQState"; 2 3 export * from "./useAvatars"; 3 4 export * from "./useCameraToggle"; 4 5 export * from "./useDocumentTitle";
+37
js/components/src/hooks/useAQState.ts
··· 1 + import { useEffect, useState } from "react"; 2 + import storage from "../storage"; 3 + 4 + export function useAQState<T>( 5 + key: string, 6 + defaultValue: T, 7 + ): [T, (value: T) => void] { 8 + const [state, setState] = useState<T>(defaultValue); 9 + const [isLoaded, setIsLoaded] = useState(false); 10 + 11 + useEffect(() => { 12 + const loadFromStorage = async () => { 13 + try { 14 + const stored = await storage.getItem(key); 15 + if (stored !== null) { 16 + setState(JSON.parse(stored)); 17 + } 18 + } catch (error) { 19 + console.error(`Failed to load ${key} from storage:`, error); 20 + } finally { 21 + setIsLoaded(true); 22 + } 23 + }; 24 + loadFromStorage(); 25 + }, [key]); 26 + 27 + const setStoredState = (value: T) => { 28 + setState(value); 29 + if (isLoaded) { 30 + storage.setItem(key, JSON.stringify(value)).catch((error) => { 31 + console.error(`Failed to save ${key} to storage:`, error); 32 + }); 33 + } 34 + }; 35 + 36 + return [state, setStoredState]; 37 + }
+8
js/components/src/hooks/useLivestreamInfo.ts
··· 9 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 + const stopIngest = usePlayerStore((x) => x.stopIngest); 12 13 13 14 const createStreamRecord = useCreateStreamRecord(); 14 15 ··· 54 55 } 55 56 }; 56 57 58 + // Stop the current broadcast 59 + const toggleStopStream = () => { 60 + console.log("Stopping stream..."); 61 + stopIngest(); 62 + }; 63 + 57 64 return { 58 65 ingest, 59 66 profile, ··· 67 74 setIngestStarting, 68 75 handleSubmit, 69 76 toggleGoLive, 77 + toggleStopStream, 70 78 }; 71 79 }
+1
js/components/src/index.tsx
··· 41 41 export * from "./components/stream-notification"; 42 42 export * from "./lib/stream-notifications"; 43 43 44 + export * from "./utils/did"; 44 45 export * from "./utils/format-handle"; 45 46 46 47 export { DanmuOverlay } from "./components/danmu/danmu-overlay";
+1
js/components/src/livestream-store/websocket-consumer.tsx
··· 80 80 chatProfile: (message as any).chatProfile, 81 81 replyTo: (message as any).replyTo, 82 82 deleted: message.deleted, 83 + badges: message.badges, 83 84 }; 84 85 state = reduceChat(state, [hydrated], [], []); 85 86 } else if (PlaceStreamSegment.isRecord(message)) {
+3
js/components/src/player-store/player-state.tsx
··· 63 63 ingestAutoStart?: boolean; 64 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 65 66 + /** stop ingest process, again with a slight delay to allow UI to update */ 67 + stopIngest: () => void; 68 + 66 69 /** Timestamp (number) when ingest started, or null if not started */ 67 70 ingestStarted: number | null; 68 71
+17
js/components/src/player-store/player-store.tsx
··· 53 53 setIngestStarted: (timestamp: number | null) => 54 54 set(() => ({ ingestStarted: timestamp })), 55 55 56 + stopIngest: () => { 57 + set(() => ({ 58 + ingestLive: false, 59 + ingestConnectionState: "new", 60 + ingestStarted: null, 61 + })), 62 + setTimeout( 63 + () => 64 + set(() => ({ 65 + ingestLive: false, 66 + ingestConnectionState: "new", 67 + ingestStarted: null, 68 + })), 69 + 200, 70 + ); 71 + }, 72 + 56 73 fullscreen: false, 57 74 setFullscreen: (isFullscreen: boolean) => 58 75 set(() => ({ fullscreen: isFullscreen })),
+60 -1
js/components/src/streamplace-store/branding.tsx
··· 25 25 }); 26 26 }; 27 27 28 + const PropsInHeader = [ 29 + "siteTitle", 30 + "siteDescription", 31 + "primaryColor", 32 + "accentColor", 33 + "defaultStreamer", 34 + "mainLogo", 35 + "favicon", 36 + "sidebarBg", 37 + "legalLinks", 38 + ]; 39 + 40 + function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 + const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 + if (meta && meta.getAttribute("content")) { 44 + let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 + } 47 + 48 + return null; 49 + } 50 + 28 51 // hook to fetch broadcaster DID (unauthenticated) 29 52 export function useFetchBroadcasterDID() { 30 53 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 31 54 const store = getStreamplaceStoreFromContext(); 55 + 56 + // prefetch from meta records, if on web 57 + useEffect(() => { 58 + if (typeof window !== "undefined" && window.document) { 59 + try { 60 + const metaRecords = PropsInHeader.reduce( 61 + (acc, key) => { 62 + const meta = document.querySelector( 63 + `meta[name="internal-brand:${key}`, 64 + ); 65 + // hrmmmmmmmmmmmm 66 + if (meta && meta.getAttribute("content")) { 67 + let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 + } 70 + return acc; 71 + }, 72 + {} as Record<string, BrandingAsset>, 73 + ); 74 + 75 + console.log("Found meta records for broadcaster DID:", metaRecords); 76 + // filter out all non-text values, can get on second fetch? 77 + for (const key of Object.keys(metaRecords)) { 78 + if (metaRecords[key].mimeType != "text/plain") { 79 + delete metaRecords[key]; 80 + } 81 + } 82 + } catch (e) { 83 + console.warn("Failed to parse broadcaster DID from meta tags", e); 84 + } 85 + } 86 + }, []); 32 87 33 88 return useCallback(async () => { 34 89 try { ··· 140 195 141 196 // hook to get a specific branding asset by key 142 197 export function useBrandingAsset(key: string): BrandingAsset | undefined { 143 - return useStreamplaceStore((state) => state.branding?.[key]); 198 + return ( 199 + useStreamplaceStore((state) => state.branding?.[key]) || 200 + getMetaContent(key) || 201 + undefined 202 + ); 144 203 } 145 204 146 205 // convenience hook for main logo
+61
js/components/src/utils/did.ts
··· 1 + export interface DIDDocument { 2 + id: string; 3 + service?: Array<{ 4 + id: string; 5 + type?: string; 6 + serviceEndpoint?: string; 7 + }>; 8 + [key: string]: any; 9 + } 10 + 11 + export async function resolveDIDDocument(did: string): Promise<DIDDocument> { 12 + let didDocUrl: string; 13 + 14 + if (did.startsWith("did:web:")) { 15 + // For did:web, construct the URL directly 16 + const domain = did.replace("did:web:", "").replace(/:/g, "/"); 17 + didDocUrl = `https://${domain}/.well-known/did.json`; 18 + } else if (did.startsWith("did:plc:")) { 19 + // For did:plc, use plc.directory 20 + didDocUrl = `https://plc.directory/${did}`; 21 + } else { 22 + throw new Error(`Unsupported DID method: ${did}`); 23 + } 24 + 25 + const response = await fetch(didDocUrl); 26 + if (!response.ok) { 27 + throw new Error( 28 + `Failed to resolve DID document for ${did}: ${response.status}`, 29 + ); 30 + } 31 + 32 + return response.json(); 33 + } 34 + 35 + export function getPDSServiceEndpoint(didDoc: DIDDocument): string { 36 + const pdsService = didDoc.service?.find((s) => s.id === "#atproto_pds"); 37 + 38 + if (!pdsService?.serviceEndpoint) { 39 + throw new Error("No PDS service endpoint found in DID document"); 40 + } 41 + 42 + return pdsService.serviceEndpoint; 43 + } 44 + 45 + export async function getBlob( 46 + did: string, 47 + cid: string, 48 + didDoc?: DIDDocument, 49 + ): Promise<Blob> { 50 + const doc = didDoc || (await resolveDIDDocument(did)); 51 + const pdsEndpoint = getPDSServiceEndpoint(doc); 52 + 53 + const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 54 + 55 + const response = await fetch(blobUrl); 56 + if (!response.ok) { 57 + throw new Error(`Failed to fetch blob: ${response.status}`); 58 + } 59 + 60 + return response.blob(); 61 + }
+4
js/docs/astro.config.mjs
··· 81 81 autogenerate: { directory: "guides/installing" }, 82 82 }, 83 83 { 84 + label: "Features (Dev)", 85 + autogenerate: { directory: "features-dev" }, 86 + }, 87 + { 84 88 label: "Video Metadata", 85 89 autogenerate: { directory: "video-metadata" }, 86 90 },
+3 -2
js/docs/package.json
··· 1 1 { 2 2 "name": "streamplace-docs", 3 3 "type": "module", 4 - "version": "0.9.5", 4 + "version": "0.9.9", 5 5 "scripts": { 6 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 7 "start": "astro dev --host 0.0.0.0 --port 38082", ··· 23 23 }, 24 24 "devDependencies": { 25 25 "starlight-sidebar-topics": "^0.6.2" 26 - } 26 + }, 27 + "private": true 27 28 }
+39
js/docs/src/content/docs/features-dev/badges.md
··· 1 + --- 2 + title: badges system 3 + description: user badges for chat messages 4 + --- 5 + 6 + ## Overview 7 + 8 + Badges appear next to usernames in chat messages. they're small icons that indicate status (streamer, mod, vip, etc.). There will be max 3 badges shown at once. One of the badges is server-based (e.g. streamer, mod, node staff badge), but the other two can be selected from a pool of cosmetic badges (such as subscription badges, event badges et al.). These cosmetic badges are cryptographically signed by the issuing party, and all the user needs to do is apply them to their chat profile. Note that certain badges may appear/disappear based on the current streamer's chat tktk. 9 + 10 + ## Lexicon schemas 11 + 12 + We have three relevant lexicons. 13 + 14 + 1. **`place.stream.badge.defs`** - badge definitions and view model 15 + 16 + - defines known badge types: `mod`, `streamer`, `vip` 17 + - `badgeView` object: `{badgeType, issuer, recipient, signature?}` 18 + 19 + 2. **`place.stream.badge.issuance`** - record of badge grant 20 + 21 + - stored as atproto record (key: tid) 22 + - issued by streamer or other authorized entity 23 + - example: streamer issues vip badge to a user 24 + 25 + 3. **`place.stream.badge.display`** - user's badge selection 26 + - user-controlled record defining which badges to show 27 + - array of up to 3 `badgeSelection` objects 28 + - first slot server-controlled (mod/streamer/staff), second slot is streamer-specific (vip, subscription), third slot is user-set (event, staff2, node subscription, etc.) 29 + 30 + :::note 31 + This may get changed to be in the user's chat profile? Maybe we could have a "main" chat profile and a streamer-specific profile? 32 + ::: 33 + 34 + ## TODO 35 + 36 + - [ ] implement cryptographic signatures for badge issuance 37 + - [ ] implement badge issuance ui (streamer grants vip badges) 38 + - [ ] implement badge selection ui (users choose which badges to display) 39 + - [ ] add more badge types (subscriber, founder, staff, etc)
+108
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md
··· 1 + --- 2 + title: place.stream.badge.defs 3 + description: Reference for the place.stream.badge.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="badgeview"></a> 11 + 12 + ### `badgeView` 13 + 14 + **Type:** `object` 15 + 16 + View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | 22 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer` | 23 + | `issuer` | `string` | โœ… | DID of the badge issuer. | Format: `did` | 24 + | `recipient` | `string` | โœ… | DID of the badge recipient. | Format: `did` | 25 + | `signature` | `string` | โŒ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 26 + 27 + --- 28 + 29 + <a name="mod"></a> 30 + 31 + ### `mod` 32 + 33 + **Type:** `token` 34 + 35 + This user is a moderator. Displayed with a sword icon. 36 + 37 + --- 38 + 39 + <a name="streamer"></a> 40 + 41 + ### `streamer` 42 + 43 + **Type:** `token` 44 + 45 + This user is the streamer. Displayed with a star icon. 46 + 47 + --- 48 + 49 + <a name="vip"></a> 50 + 51 + ### `vip` 52 + 53 + **Type:** `token` 54 + 55 + This user is a very important person. 56 + 57 + --- 58 + 59 + ## Lexicon Source 60 + 61 + ```json 62 + { 63 + "lexicon": 1, 64 + "id": "place.stream.badge.defs", 65 + "defs": { 66 + "badgeView": { 67 + "type": "object", 68 + "required": ["badgeType", "issuer", "recipient"], 69 + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", 70 + "properties": { 71 + "badgeType": { 72 + "type": "string", 73 + "knownValues": [ 74 + "place.stream.badge.defs#mod", 75 + "place.stream.badge.defs#streamer" 76 + ] 77 + }, 78 + "issuer": { 79 + "type": "string", 80 + "format": "did", 81 + "description": "DID of the badge issuer." 82 + }, 83 + "recipient": { 84 + "type": "string", 85 + "format": "did", 86 + "description": "DID of the badge recipient." 87 + }, 88 + "signature": { 89 + "type": "string", 90 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 91 + } 92 + } 93 + }, 94 + "mod": { 95 + "type": "token", 96 + "description": "This user is a moderator. Displayed with a sword icon." 97 + }, 98 + "streamer": { 99 + "type": "token", 100 + "description": "This user is the streamer. Displayed with a star icon." 101 + }, 102 + "vip": { 103 + "type": "token", 104 + "description": "This user is a very important person." 105 + } 106 + } 107 + } 108 + ```
+90
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md
··· 1 + --- 2 + title: place.stream.badge.display 3 + description: Reference for the place.stream.badge.display lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record issuing a badge to a user. 17 + 18 + **Record Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | -------- | --------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | 22 + | `badges` | Array of [`#badgeSelection`](#badgeselection) | โœ… | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. | Max Items: 3 | 23 + 24 + --- 25 + 26 + <a name="badgeselection"></a> 27 + 28 + ### `badgeSelection` 29 + 30 + **Type:** `object` 31 + 32 + A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. 33 + 34 + **Properties:** 35 + 36 + | Name | Type | Req'd | Description | Constraints | 37 + | ----------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | 38 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#vip` | 39 + | `issuance` | `string` | โŒ | URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. | Format: `at-uri` | 40 + 41 + --- 42 + 43 + ## Lexicon Source 44 + 45 + ```json 46 + { 47 + "lexicon": 1, 48 + "id": "place.stream.badge.display", 49 + "defs": { 50 + "main": { 51 + "type": "record", 52 + "description": "Record issuing a badge to a user.", 53 + "record": { 54 + "type": "object", 55 + "required": ["badges"], 56 + "properties": { 57 + "badges": { 58 + "type": "array", 59 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", 60 + "maxLength": 3, 61 + "items": { 62 + "type": "ref", 63 + "ref": "#badgeSelection" 64 + } 65 + } 66 + } 67 + } 68 + }, 69 + "badgeSelection": { 70 + "type": "object", 71 + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", 72 + "required": ["badgeType"], 73 + "properties": { 74 + "badgeType": { 75 + "type": "string", 76 + "knownValues": [ 77 + "place.stream.badge.defs#mod", 78 + "place.stream.badge.defs#vip" 79 + ] 80 + }, 81 + "issuance": { 82 + "type": "string", 83 + "format": "at-uri", 84 + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." 85 + } 86 + } 87 + } 88 + } 89 + } 90 + ```
+63
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md
··· 1 + --- 2 + title: place.stream.badge.issuance 3 + description: Reference for the place.stream.badge.issuance lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record issuing a badge to a user. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------- | 24 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#vip` | 25 + | `recipient` | `string` | โœ… | DID of the badge recipient. | Format: `did` | 26 + | `signature` | `string` | โœ… | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 27 + 28 + --- 29 + 30 + ## Lexicon Source 31 + 32 + ```json 33 + { 34 + "lexicon": 1, 35 + "id": "place.stream.badge.issuance", 36 + "defs": { 37 + "main": { 38 + "type": "record", 39 + "key": "tid", 40 + "description": "Record issuing a badge to a user.", 41 + "record": { 42 + "type": "object", 43 + "required": ["badgeType", "recipient", "signature"], 44 + "properties": { 45 + "badgeType": { 46 + "type": "string", 47 + "knownValues": ["place.stream.badge.defs#vip"] 48 + }, 49 + "recipient": { 50 + "type": "string", 51 + "format": "did", 52 + "description": "DID of the badge recipient." 53 + }, 54 + "signature": { 55 + "type": "string", 56 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 57 + } 58 + } 59 + } 60 + } 61 + } 62 + } 63 + ```
+20 -10
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 15 15 16 16 **Properties:** 17 17 18 - | Name | Type | Req'd | Description | Constraints | 19 - | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------------------------------------------- | ------------------ | 20 - | `uri` | `string` | โœ… | | Format: `at-uri` | 21 - | `cid` | `string` | โœ… | | Format: `cid` | 22 - | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | | | 23 - | `record` | `unknown` | โœ… | | | 24 - | `indexedAt` | `string` | โœ… | | Format: `datetime` | 25 - | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | | | 26 - | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | โŒ | | | 27 - | `deleted` | `boolean` | โŒ | If true, this message has been deleted or labeled and should be cleared from the cache | | 18 + | Name | Type | Req'd | Description | Constraints | 19 + | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | 20 + | `uri` | `string` | โœ… | | Format: `at-uri` | 21 + | `cid` | `string` | โœ… | | Format: `cid` | 22 + | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | | | 23 + | `record` | `unknown` | โœ… | | | 24 + | `indexedAt` | `string` | โœ… | | Format: `datetime` | 25 + | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | | | 26 + | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | โŒ | | | 27 + | `deleted` | `boolean` | โŒ | If true, this message has been deleted or labeled and should be cleared from the cache | | 28 + | `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | โŒ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. | Max Items: 3 | 28 29 29 30 --- 30 31 ··· 69 70 "deleted": { 70 71 "type": "boolean", 71 72 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 73 + }, 74 + "badges": { 75 + "type": "array", 76 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 77 + "maxLength": 3, 78 + "items": { 79 + "type": "ref", 80 + "ref": "place.stream.badge.defs#badgeView" 81 + } 72 82 } 73 83 } 74 84 }
+74
js/docs/src/content/docs/lex-reference/openapi.json
··· 1956 1956 ] 1957 1957 } 1958 1958 }, 1959 + "/xrpc/com.atproto.sync.getRepo": { 1960 + "get": { 1961 + "summary": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 1962 + "operationId": "com.atproto.sync.getRepo", 1963 + "tags": ["com.atproto.sync"], 1964 + "responses": { 1965 + "200": { 1966 + "description": "Success", 1967 + "content": { 1968 + "application/vnd.ipld.car": { 1969 + "schema": {} 1970 + } 1971 + } 1972 + }, 1973 + "400": { 1974 + "description": "Bad Request", 1975 + "content": { 1976 + "application/json": { 1977 + "schema": { 1978 + "type": "object", 1979 + "required": ["error", "message"], 1980 + "properties": { 1981 + "error": { 1982 + "type": "string", 1983 + "oneOf": [ 1984 + { 1985 + "const": "RepoNotFound" 1986 + }, 1987 + { 1988 + "const": "RepoTakendown" 1989 + }, 1990 + { 1991 + "const": "RepoSuspended" 1992 + }, 1993 + { 1994 + "const": "RepoDeactivated" 1995 + } 1996 + ] 1997 + }, 1998 + "message": { 1999 + "type": "string" 2000 + } 2001 + } 2002 + } 2003 + } 2004 + } 2005 + } 2006 + }, 2007 + "parameters": [ 2008 + { 2009 + "name": "did", 2010 + "in": "query", 2011 + "required": true, 2012 + "description": "The DID of the repo.", 2013 + "schema": { 2014 + "type": "string", 2015 + "description": "The DID of the repo.", 2016 + "format": "did" 2017 + } 2018 + }, 2019 + { 2020 + "name": "since", 2021 + "in": "query", 2022 + "required": false, 2023 + "description": "The revision ('rev') of the repo to create a diff from.", 2024 + "schema": { 2025 + "type": "string", 2026 + "description": "The revision ('rev') of the repo to create a diff from.", 2027 + "format": "tid" 2028 + } 2029 + } 2030 + ] 2031 + } 2032 + }, 1959 2033 "/xrpc/com.atproto.sync.listRepos": { 1960 2034 "get": { 1961 2035 "summary": "Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.",
+1 -1
lerna.json
··· 1 1 { 2 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.9.5", 3 + "version": "0.9.9", 4 4 "npmClient": "pnpm" 5 5 }
+35
lexicons/com/atproto/sync/getRepo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.sync.getRepo", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["did"], 11 + "properties": { 12 + "did": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "The DID of the repo." 16 + }, 17 + "since": { 18 + "type": "string", 19 + "format": "tid", 20 + "description": "The revision ('rev') of the repo to create a diff from." 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/vnd.ipld.car" 26 + }, 27 + "errors": [ 28 + { "name": "RepoNotFound" }, 29 + { "name": "RepoTakendown" }, 30 + { "name": "RepoSuspended" }, 31 + { "name": "RepoDeactivated" } 32 + ] 33 + } 34 + } 35 + }
+46
lexicons/place/stream/badge/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.defs", 4 + "defs": { 5 + "badgeView": { 6 + "type": "object", 7 + "required": ["badgeType", "issuer", "recipient"], 8 + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", 9 + "properties": { 10 + "badgeType": { 11 + "type": "string", 12 + "knownValues": [ 13 + "place.stream.badge.defs#mod", 14 + "place.stream.badge.defs#streamer" 15 + ] 16 + }, 17 + "issuer": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge issuer." 21 + }, 22 + "recipient": { 23 + "type": "string", 24 + "format": "did", 25 + "description": "DID of the badge recipient." 26 + }, 27 + "signature": { 28 + "type": "string", 29 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 30 + } 31 + } 32 + }, 33 + "mod": { 34 + "type": "token", 35 + "description": "This user is a moderator. Displayed with a sword icon." 36 + }, 37 + "streamer": { 38 + "type": "token", 39 + "description": "This user is the streamer. Displayed with a star icon." 40 + }, 41 + "vip": { 42 + "type": "token", 43 + "description": "This user is a very important person." 44 + } 45 + } 46 + }
+44
lexicons/place/stream/badge/display.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.display", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record issuing a badge to a user.", 8 + "record": { 9 + "type": "object", 10 + "required": ["badges"], 11 + "properties": { 12 + "badges": { 13 + "type": "array", 14 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", 15 + "maxLength": 3, 16 + "items": { 17 + "type": "ref", 18 + "ref": "#badgeSelection" 19 + } 20 + } 21 + } 22 + } 23 + }, 24 + "badgeSelection": { 25 + "type": "object", 26 + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", 27 + "required": ["badgeType"], 28 + "properties": { 29 + "badgeType": { 30 + "type": "string", 31 + "knownValues": [ 32 + "place.stream.badge.defs#mod", 33 + "place.stream.badge.defs#vip" 34 + ] 35 + }, 36 + "issuance": { 37 + "type": "string", 38 + "format": "at-uri", 39 + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." 40 + } 41 + } 42 + } 43 + } 44 + }
+30
lexicons/place/stream/badge/issuance.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.issuance", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record issuing a badge to a user.", 9 + "record": { 10 + "type": "object", 11 + "required": ["badgeType", "recipient", "signature"], 12 + "properties": { 13 + "badgeType": { 14 + "type": "string", 15 + "knownValues": ["place.stream.badge.defs#vip"] 16 + }, 17 + "recipient": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge recipient." 21 + }, 22 + "signature": { 23 + "type": "string", 24 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+9
lexicons/place/stream/chat/defs.json
··· 25 25 "deleted": { 26 26 "type": "boolean", 27 27 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 28 + }, 29 + "badges": { 30 + "type": "array", 31 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 32 + "maxLength": 3, 33 + "items": { 34 + "type": "ref", 35 + "ref": "place.stream.badge.defs#badgeView" 36 + } 28 37 } 29 38 } 30 39 }
+6 -58
pkg/api/api.go
··· 35 35 "stream.place/streamplace/pkg/director" 36 36 apierrors "stream.place/streamplace/pkg/errors" 37 37 "stream.place/streamplace/pkg/linking" 38 + "stream.place/streamplace/pkg/localdb" 38 39 "stream.place/streamplace/pkg/log" 39 40 "stream.place/streamplace/pkg/media" 40 41 "stream.place/streamplace/pkg/mist/mistconfig" ··· 56 57 CLI *config.CLI 57 58 Model model.Model 58 59 StatefulDB *statedb.StatefulDB 60 + LocalDB localdb.LocalDB 59 61 Updater *Updater 60 62 Signer *eip712.EIP712Signer 61 63 Mimes map[string]string ··· 93 95 mu sync.RWMutex 94 96 } 95 97 96 - func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, statefulDB *statedb.StatefulDB, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director, op *oatproxy.OATProxy) (*StreamplaceAPI, error) { 98 + func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, statefulDB *statedb.StatefulDB, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director, op *oatproxy.OATProxy, ldb localdb.LocalDB) (*StreamplaceAPI, error) { 97 99 updater, err := PrepareUpdater(cli) 98 100 if err != nil { 99 101 return nil, err ··· 117 119 sessionsLock: sync.RWMutex{}, 118 120 rtmpSessions: make(map[string]*media.RTMPSession), 119 121 rtmpSessionsLock: sync.Mutex{}, 122 + LocalDB: ldb, 120 123 } 121 124 a.Mimes, err = updater.GetMimes() 122 125 if err != nil { ··· 152 155 Recorder: metrics.NewRecorder(metrics.Config{}), 153 156 }) 154 157 var xrpc http.Handler 155 - xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus) 158 + xrpc, err := spxrpc.NewServer(ctx, a.CLI, a.Model, a.StatefulDB, a.op, mdlw, a.ATSync, a.Bus, a.LocalDB) 156 159 if err != nil { 157 160 return nil, err 158 161 } ··· 203 206 addHandle(apiRouter, "GET", "/api/chat/:repoDID", a.HandleChat(ctx)) 204 207 addHandle(apiRouter, "GET", "/api/websocket/:repoDID", a.HandleWebsocket(ctx)) 205 208 addHandle(apiRouter, "GET", "/api/livestream/:repoDID", a.HandleLivestream(ctx)) 206 - addHandle(apiRouter, "GET", "/api/segment/recent", a.HandleRecentSegments(ctx)) 207 - addHandle(apiRouter, "GET", "/api/segment/recent/:repoDID", a.HandleUserRecentSegments(ctx)) 208 209 addHandle(apiRouter, "GET", "/api/bluesky/resolve/:handle", a.HandleBlueskyResolve(ctx)) 209 210 addHandle(apiRouter, "GET", "/api/view-count/:user", a.HandleViewCount(ctx)) 210 211 addHandle(apiRouter, "GET", "/api/clip/:user/:file", a.HandleClip(ctx)) ··· 271 272 if err != nil { 272 273 return nil, err 273 274 } 274 - linker, err := linking.NewLinker(ctx, bs) 275 + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) 275 276 if err != nil { 276 277 return nil, err 277 278 } ··· 558 559 return 559 560 } 560 561 w.WriteHeader(201) 561 - } 562 - } 563 - 564 - func (a *StreamplaceAPI) HandleRecentSegments(ctx context.Context) httprouter.Handle { 565 - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 566 - segs, err := a.Model.MostRecentSegments() 567 - if err != nil { 568 - apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 569 - return 570 - } 571 - bs, err := json.Marshal(segs) 572 - if err != nil { 573 - apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 574 - return 575 - } 576 - w.Header().Add("Content-Type", "application/json") 577 - if _, err := w.Write(bs); err != nil { 578 - log.Error(ctx, "error writing response", "error", err) 579 - } 580 - } 581 - } 582 - 583 - func (a *StreamplaceAPI) HandleUserRecentSegments(ctx context.Context) httprouter.Handle { 584 - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 585 - user := params.ByName("repoDID") 586 - if user == "" { 587 - apierrors.WriteHTTPBadRequest(w, "user required", nil) 588 - return 589 - } 590 - user, err := a.NormalizeUser(ctx, user) 591 - if err != nil { 592 - apierrors.WriteHTTPNotFound(w, "user not found", err) 593 - return 594 - } 595 - seg, err := a.Model.LatestSegmentForUser(user) 596 - if err != nil { 597 - apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 598 - return 599 - } 600 - streamplaceSeg, err := seg.ToStreamplaceSegment() 601 - if err != nil { 602 - apierrors.WriteHTTPInternalServerError(w, "could not convert segment to streamplace segment", err) 603 - return 604 - } 605 - bs, err := json.Marshal(streamplaceSeg) 606 - if err != nil { 607 - apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 608 - return 609 - } 610 - w.Header().Add("Content-Type", "application/json") 611 - if _, err := w.Write(bs); err != nil { 612 - log.Error(ctx, "error writing response", "error", err) 613 - } 614 562 } 615 563 } 616 564
+2 -2
pkg/api/api_internal.go
··· 298 298 errors.WriteHTTPBadRequest(w, "id required", nil) 299 299 return 300 300 } 301 - segment, err := a.Model.GetSegment(id) 301 + segment, err := a.LocalDB.GetSegment(id) 302 302 if err != nil { 303 303 errors.WriteHTTPBadRequest(w, err.Error(), err) 304 304 return ··· 553 553 } 554 554 after := time.Now().Add(-time.Duration(secs) * time.Second) 555 555 w.Header().Set("Content-Type", "video/mp4") 556 - err = media.ClipUser(ctx, a.Model, a.CLI, user, w, nil, &after) 556 + err = media.ClipUser(ctx, a.LocalDB, a.CLI, user, w, nil, &after) 557 557 if err != nil { 558 558 errors.WriteHTTPInternalServerError(w, "unable to clip user", err) 559 559 return
+1 -1
pkg/api/playback.go
··· 272 272 errors.WriteHTTPNotFound(w, "user not found", err) 273 273 return 274 274 } 275 - thumb, err := a.Model.LatestThumbnailForUser(user) 275 + thumb, err := a.LocalDB.LatestThumbnailForUser(user) 276 276 if err != nil { 277 277 errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) 278 278 return
+10 -1
pkg/api/websocket.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "net" 7 8 "net/http" 8 9 "time" ··· 12 13 "github.com/gorilla/websocket" 13 14 "github.com/julienschmidt/httprouter" 14 15 16 + "stream.place/streamplace/pkg/atproto" 15 17 apierrors "stream.place/streamplace/pkg/errors" 16 18 "stream.place/streamplace/pkg/log" 17 19 "stream.place/streamplace/pkg/renditions" ··· 181 183 }() 182 184 183 185 go func() { 184 - seg, err := a.Model.LatestSegmentForUser(repoDID) 186 + seg, err := a.LocalDB.LatestSegmentForUser(repoDID) 185 187 if err != nil { 186 188 log.Error(ctx, "could not get replies", "error", err) 187 189 return ··· 237 239 log.Error(ctx, "could not get chat messages", "error", err) 238 240 return 239 241 } 242 + 243 + // Add mod badges to messages 244 + issuerDID := fmt.Sprintf("did:web:%s", a.CLI.BroadcasterHost) 240 245 for _, message := range messages { 246 + err := atproto.AddModBadgeIfApplicable(ctx, message, repoDID, issuerDID, a.Model) 247 + if err != nil { 248 + log.Error(ctx, "failed to add mod badge to message", "error", err) 249 + } 241 250 initialBurst <- message 242 251 } 243 252 }()
+30 -7
pkg/aqtime/aqtime.go
··· 7 7 "time" 8 8 ) 9 9 10 + // RE matches the canonical internal format: 2006-01-02T15:04:05.000Z 11 + // It also accepts the file-safe variant with dashes/dots swapped, for backward compat. 10 12 var RE *regexp.Regexp 11 - var Pattern string = `^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z$` 12 - 13 - type AQTime string 13 + var Pattern string = `(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z` 14 14 15 15 func init() { 16 16 RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) 17 17 } 18 18 19 19 var fstr = "2006-01-02T15:04:05.000Z" 20 + 21 + type AQTime string 20 22 21 23 // return a consistently formatted timestamp 22 24 func FromMillis(ms int64) AQTime { ··· 29 31 } 30 32 31 33 func FromString(str string) (AQTime, error) { 32 - bits := RE.FindStringSubmatch(str) 33 - if bits == nil { 34 - return "", fmt.Errorf("bad time format, expected=%s got=%s", fstr, str) 34 + // Reject -00:00 (valid RFC 3339 but disallowed by ATProto) 35 + if strings.HasSuffix(str, "-00:00") { 36 + return "", fmt.Errorf("bad time format, -00:00 timezone offset is not allowed, got=%s", str) 37 + } 38 + 39 + t, err := time.Parse(time.RFC3339Nano, str) 40 + if err != nil { 41 + // Fall back to file-safe variant (e.g. 2024-09-13T18-10-17-090Z) 42 + if bits := RE.FindStringSubmatch(str); bits != nil { 43 + if bits[2] < "01" || bits[2] > "12" || bits[3] < "01" || bits[3] > "31" || 44 + bits[4] > "23" || bits[5] > "59" || bits[6] > "60" { 45 + return "", fmt.Errorf("bad time format, invalid date/time values in %s", str) 46 + } 47 + return AQTime(str), nil 48 + } 49 + return "", fmt.Errorf("bad time format: %w", err) 35 50 } 36 - return AQTime(str), nil 51 + 52 + // Reject if UTC normalization results in a negative year 53 + utc := t.UTC() 54 + if utc.Year() < 0 { 55 + return "", fmt.Errorf("bad time format, datetime normalizes to negative year: %s", str) 56 + } 57 + 58 + // Normalize to canonical UTC millisecond format 59 + return AQTime(utc.Format(fstr)), nil 37 60 } 38 61 39 62 func FromTime(t time.Time) AQTime {
+63 -2
pkg/aqtime/aqtime_test.go
··· 35 35 } 36 36 } 37 37 38 + // Valid ATProto datetime examples from the spec 39 + // https://atproto.com/specs/lexicon#datetime 40 + func TestATProtoValidCases(t *testing.T) { 41 + tests := []struct { 42 + input string 43 + wantMs string // expected millisecond portion after normalization 44 + wantHr string // expected hour after UTC normalization 45 + wantMin string 46 + }{ 47 + {"1985-04-12T23:20:50.123Z", "123", "23", "20"}, 48 + {"1985-04-12T23:20:50.123456Z", "123", "23", "20"}, 49 + {"1985-04-12T23:20:50.120Z", "120", "23", "20"}, 50 + {"1985-04-12T23:20:50.120000Z", "120", "23", "20"}, 51 + {"0001-01-01T00:00:00.000Z", "000", "00", "00"}, 52 + {"0000-01-01T00:00:00.000Z", "000", "00", "00"}, 53 + {"1985-04-12T23:20:50.12345678912345Z", "123", "23", "20"}, 54 + {"1985-04-12T23:20:50Z", "000", "23", "20"}, 55 + {"1985-04-12T23:20:50.0Z", "000", "23", "20"}, 56 + {"1985-04-12T23:20:50.123+00:00", "123", "23", "20"}, 57 + {"1985-04-12T23:20:50.123-07:00", "123", "06", "20"}, // 23+7=30 -> next day 06:20 58 + } 59 + for _, tt := range tests { 60 + t.Run(tt.input, func(t *testing.T) { 61 + aqt, err := FromString(tt.input) 62 + require.NoError(t, err, "input: %s", tt.input) 63 + _, _, _, hr, min, _, ms := aqt.Parts() 64 + require.Equal(t, tt.wantMs, ms, "millis mismatch for %s", tt.input) 65 + require.Equal(t, tt.wantHr, hr, "hour mismatch for %s", tt.input) 66 + require.Equal(t, tt.wantMin, min, "minute mismatch for %s", tt.input) 67 + }) 68 + } 69 + } 70 + 38 71 func TestBadCases(t *testing.T) { 39 72 for _, str := range []string{ 73 + // existing cases 40 74 "prefix2024-09-13T18:10:17.090Z", 41 75 "2024-09-13T18-10-17-090Zsuffix", 42 76 "2024-09-13T18-10-17-090ZZZZ", 43 77 "2024-09-13T18-10-17*090ZZZZ", 78 + // ATProto spec invalid examples 79 + "1985-04-12", 80 + "1985-04-12T23:20Z", 81 + "1985-04-12T23:20:5Z", 82 + "1985-04-12T23:20:50.123", 83 + "+001985-04-12T23:20:50.123Z", 84 + "23:20:50.123Z", 85 + "-1985-04-12T23:20:50.123Z", 86 + "1985-4-12T23:20:50.123Z", 87 + "01985-04-12T23:20:50.123Z", 88 + "1985-04-12T23:20:50.123+00", 89 + "1985-04-12T23:20:50.123+0000", 90 + // ISO-8601 strict capitalization 91 + "1985-04-12t23:20:50.123Z", 92 + "1985-04-12T23:20:50.123z", 93 + // RFC-3339, but not ISO-8601 94 + "1985-04-12T23:20:50.123-00:00", 95 + "1985-04-12 23:20:50.123Z", 96 + // timezone is required 97 + "1985-04-12T23:20:50.123", 98 + // syntax looks ok, but datetime is not valid 99 + "1985-04-12T23:99:50.123Z", 100 + "1985-00-12T23:20:50.123Z", 101 + // ISO-8601, but normalizes to a negative time 102 + "0000-01-01T00:00:00+01:00", 44 103 } { 45 - _, err := FromString(str) 46 - require.Error(t, err) 104 + t.Run(str, func(t *testing.T) { 105 + _, err := FromString(str) 106 + require.Error(t, err, "expected error for: %s", str) 107 + }) 47 108 } 48 109 }
+2 -4
pkg/atproto/atproto.go
··· 231 231 "https://w3id.org/security/multikey/v1", 232 232 "https://w3id.org/security/suites/secp256k1-2019/v1", 233 233 }, 234 - "id": fmt.Sprintf("did:web:%s", host), 235 - "alsoKnownAs": []string{ 236 - fmt.Sprintf("at://%s", host), 237 - }, 234 + "id": fmt.Sprintf("did:web:%s", host), 235 + "alsoKnownAs": []string{}, 238 236 "service": []map[string]any{ 239 237 { 240 238 "id": "#bsky_fg",
+61
pkg/atproto/badges.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "stream.place/streamplace/pkg/constants" 8 + "stream.place/streamplace/pkg/log" 9 + "stream.place/streamplace/pkg/model" 10 + "stream.place/streamplace/pkg/streamplace" 11 + ) 12 + 13 + // AddModBadgeIfApplicable checks if a message author has mod permissions for the streamer 14 + // and adds a mod or streamer badge as the first badge (server-controlled). 15 + // - If the author is the streamer, adds a "streamer" badge 16 + // - If the author has moderation permissions, adds a "mod" badge 17 + func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_MessageView, streamerDID string, issuerDID string, m model.Model) error { 18 + if message == nil { 19 + return fmt.Errorf("message is nil") 20 + } 21 + 22 + authorDID := message.Author.Did 23 + 24 + var badge *streamplace.BadgeDefs_BadgeView 25 + 26 + // Check if author is the streamer 27 + if authorDID == streamerDID { 28 + badge = &streamplace.BadgeDefs_BadgeView{ 29 + BadgeType: constants.BadgeTypeStreamer, 30 + Issuer: issuerDID, 31 + Recipient: authorDID, 32 + } 33 + } else { 34 + // Check if author has any moderation permissions for the streamer 35 + delegations, err := m.GetModerationDelegations(ctx, streamerDID, authorDID) 36 + if err != nil { 37 + log.Error(ctx, "failed to get moderation delegations", "err", err, "authorDID", authorDID, "streamerDID", streamerDID) 38 + return err 39 + } 40 + 41 + // If the author has any delegations (meaning they're a moderator), add a mod badge 42 + if len(delegations) > 0 { 43 + badge = &streamplace.BadgeDefs_BadgeView{ 44 + BadgeType: constants.BadgeTypeMod, 45 + Issuer: issuerDID, 46 + Recipient: authorDID, 47 + } 48 + } 49 + } 50 + 51 + // Prepend the badge if one was created (server-controlled badge is first) 52 + if badge != nil { 53 + if message.Badges == nil { 54 + message.Badges = []*streamplace.BadgeDefs_BadgeView{badge} 55 + } else { 56 + message.Badges = append([]*streamplace.BadgeDefs_BadgeView{badge}, message.Badges...) 57 + } 58 + } 59 + 60 + return nil 61 + }
+100
pkg/atproto/badges_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/stretchr/testify/require" 12 + "stream.place/streamplace/pkg/model" 13 + "stream.place/streamplace/pkg/streamplace" 14 + ) 15 + 16 + func TestAddModBadge(t *testing.T) { 17 + ctx := context.Background() 18 + 19 + mod, err := model.MakeDB(":memory:") 20 + require.NoError(t, err) 21 + 22 + streamerDID := "did:plc:streamer" 23 + moderatorDID := "did:plc:moderator" 24 + issuerDID := "did:web:example.com" 25 + 26 + // Create a chat message 27 + message := &streamplace.ChatDefs_MessageView{ 28 + LexiconTypeID: "place.stream.chat.defs#messageView", 29 + Uri: "at://test/place.stream.chat.message/123", 30 + Cid: "test-cid", 31 + Author: &bsky.ActorDefs_ProfileViewBasic{ 32 + Did: moderatorDID, 33 + Handle: "moderator.test", 34 + }, 35 + IndexedAt: "2024-01-01T00:00:00Z", 36 + } 37 + 38 + t.Run("no badge when user is not a moderator", func(t *testing.T) { 39 + msg := *message // copy 40 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 41 + require.NoError(t, err) 42 + require.Nil(t, msg.Badges, "should not have badges when user is not a moderator") 43 + }) 44 + 45 + t.Run("adds streamer badge when user is the streamer", func(t *testing.T) { 46 + msg := *message // copy 47 + msg.Author = &bsky.ActorDefs_ProfileViewBasic{ 48 + Did: streamerDID, 49 + Handle: "streamer.test", 50 + } 51 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 52 + require.NoError(t, err) 53 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is the streamer") 54 + require.Equal(t, "place.stream.badge.defs#streamer", msg.Badges[0].BadgeType) 55 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 56 + require.Equal(t, streamerDID, msg.Badges[0].Recipient) 57 + }) 58 + 59 + t.Run("adds mod badge when user has moderation permissions", func(t *testing.T) { 60 + // Grant moderation permissions to the moderator 61 + perm := &streamplace.ModerationPermission{ 62 + LexiconTypeID: "place.stream.moderation.permission", 63 + Moderator: moderatorDID, 64 + Permissions: []string{"ban", "hide"}, 65 + CreatedAt: time.Now().Format(util.ISO8601), 66 + } 67 + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") 68 + require.NoError(t, err) 69 + 70 + // Sync the permission to the model 71 + err = mod.CreateModerationDelegation(ctx, perm, aturi) 72 + require.NoError(t, err) 73 + 74 + msg := *message // copy 75 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 76 + require.NoError(t, err) 77 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is a moderator") 78 + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType) 79 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 80 + require.Equal(t, moderatorDID, msg.Badges[0].Recipient) 81 + }) 82 + 83 + t.Run("prepends mod badge to existing badges", func(t *testing.T) { 84 + // Create message with existing user-settable badge 85 + msg := *message // copy 86 + msg.Badges = []*streamplace.BadgeDefs_BadgeView{ 87 + { 88 + BadgeType: "place.stream.badges.badge#vip", 89 + Issuer: "did:web:other.com", 90 + Recipient: moderatorDID, 91 + }, 92 + } 93 + 94 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 95 + require.NoError(t, err) 96 + require.Len(t, msg.Badges, 2, "should have 2 badges") 97 + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType, "mod badge should be first") 98 + require.Equal(t, "place.stream.badges.badge#vip", msg.Badges[1].BadgeType, "vip badge should be second") 99 + }) 100 + }
+12
pkg/atproto/lexicon_repo_queries.go
··· 126 126 Value: &lexutil.LexiconTypeDecoder{Val: rec}, 127 127 }, nil 128 128 } 129 + 130 + func LexiconRepoGetRepo(ctx context.Context, since string) ([]byte, error) { 131 + buf := bytes.Buffer{} 132 + 133 + repoLock.Lock() 134 + defer repoLock.Unlock() 135 + err := CarStore.ReadUserCar(ctx, RepoUser, since, true, &buf) 136 + if err != nil { 137 + return nil, fmt.Errorf("LexiconRepoGetRepo: failed to read user car: %w", err) 138 + } 139 + return buf.Bytes(), nil 140 + }
+9 -1
pkg/atproto/sync.go
··· 74 74 return fmt.Errorf("failed to create block: %w", err) 75 75 } 76 76 block, err = atsync.Model.GetBlock(ctx, rkey.String()) 77 - if err != nil { 77 + if err != nil || block == nil { 78 78 return fmt.Errorf("failed to get block after we just saved it?!: %w", err) 79 79 } 80 80 streamplaceBlock, err := block.ToStreamplaceBlock() ··· 150 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 151 return nil 152 152 } 153 + 154 + // Add mod badge if the author is a moderator 155 + issuerDID := fmt.Sprintf("did:web:%s", atsync.CLI.BroadcasterHost) 156 + err = AddModBadgeIfApplicable(ctx, scm, rec.Streamer, issuerDID, atsync.Model) 157 + if err != nil { 158 + log.Error(ctx, "failed to add mod badge", "err", err) 159 + } 160 + 153 161 go atsync.Bus.Publish(rec.Streamer, scm) 154 162 155 163 if !isUpdate && !isFirstSync {
+12 -5
pkg/cmd/streamplace.go
··· 29 29 "stream.place/streamplace/pkg/director" 30 30 "stream.place/streamplace/pkg/gstinit" 31 31 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 32 + "stream.place/streamplace/pkg/localdb" 32 33 "stream.place/streamplace/pkg/log" 33 34 "stream.place/streamplace/pkg/media" 34 35 "stream.place/streamplace/pkg/notifications" ··· 237 238 return fmt.Errorf("error creating streamplace dir at %s:%w", cli.DataDir, err) 238 239 } 239 240 241 + ldb, err := localdb.MakeDB(cli.LocalDBURL) 242 + if err != nil { 243 + return err 244 + } 245 + 240 246 mod, err := model.MakeDB(cli.DataFilePath([]string{"index"})) 241 247 if err != nil { 242 248 return err ··· 291 297 return fmt.Errorf("failed to migrate: %w", err) 292 298 } 293 299 294 - mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync) 300 + mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync, ldb) 295 301 if err != nil { 296 302 return err 297 303 } ··· 379 385 DownstreamJWK: cli.AccessJWK, 380 386 ClientMetadata: clientMetadata, 381 387 Public: cli.PublicOAuth, 388 + HTTPClient: &aqhttp.Client, 382 389 }) 383 - d := director.NewDirector(mm, mod, &cli, b, op, state, replicator) 384 - a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op) 390 + d := director.NewDirector(mm, mod, &cli, b, op, state, replicator, ldb) 391 + a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) 385 392 if err != nil { 386 393 return err 387 394 } ··· 446 453 }) 447 454 448 455 group.Go(func() error { 449 - return storage.StartSegmentCleaner(ctx, mod, &cli) 456 + return storage.StartSegmentCleaner(ctx, ldb, &cli) 450 457 }) 451 458 452 459 group.Go(func() error { 453 - return mod.StartSegmentCleaner(ctx) 460 + return ldb.StartSegmentCleaner(ctx) 454 461 }) 455 462 456 463 group.Go(func() error {
+3
pkg/config/config.go
··· 56 56 Build *BuildFlags 57 57 DataDir string 58 58 DBURL string 59 + LocalDBURL string 59 60 EthAccountAddr string 60 61 EthKeystorePath string 61 62 EthPassword string ··· 242 243 cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations") 243 244 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") 244 245 fs.BoolVar(&cli.PlayerTelemetry, "player-telemetry", true, "enable player telemetry") 246 + fs.StringVar(&cli.LocalDBURL, "local-db-url", "sqlite://$SP_DATA_DIR/localdb.sqlite", "URL of the local database to use for storing local data") 247 + cli.dataDirFlags = append(cli.dataDirFlags, &cli.LocalDBURL) 245 248 246 249 fs.Bool("external-signing", true, "DEPRECATED, does nothing.") 247 250 fs.Bool("insecure", false, "DEPRECATED, does nothing.")
+6
pkg/constants/constants.go
··· 15 15 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 16 16 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 17 17 18 + // Streamplace badge types 19 + const ( 20 + BadgeTypeMod = "place.stream.badge.defs#mod" 21 + BadgeTypeStreamer = "place.stream.badge.defs#streamer" 22 + ) 23 + 18 24 const DID_KEY_PREFIX = "did:key" //nolint:all 19 25 const ADDRESS_KEY_PREFIX = "0x" //nolint:all 20 26
+5 -1
pkg/director/director.go
··· 9 9 "golang.org/x/sync/errgroup" 10 10 "stream.place/streamplace/pkg/bus" 11 11 "stream.place/streamplace/pkg/config" 12 + "stream.place/streamplace/pkg/localdb" 12 13 "stream.place/streamplace/pkg/log" 13 14 "stream.place/streamplace/pkg/media" 14 15 "stream.place/streamplace/pkg/model" ··· 32 33 op *oatproxy.OATProxy 33 34 statefulDB *statedb.StatefulDB 34 35 replicator replication.Replicator 36 + localDB localdb.LocalDB 35 37 } 36 38 37 - func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus, op *oatproxy.OATProxy, statefulDB *statedb.StatefulDB, replicator replication.Replicator) *Director { 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 { 38 40 return &Director{ 39 41 mm: mm, 40 42 mod: mod, ··· 45 47 op: op, 46 48 statefulDB: statefulDB, 47 49 replicator: replicator, 50 + localDB: ldb, 48 51 } 49 52 } 50 53 ··· 79 82 // Initialize notification channels (buffered size 1 for coalescing) 80 83 statusUpdateChan: make(chan struct{}, 1), 81 84 originUpdateChan: make(chan struct{}, 1), 85 + localDB: d.localDB, 82 86 } 83 87 d.streamSessions[not.Segment.RepoDID] = ss 84 88 g.Go(func() error {
+11 -7
pkg/director/stream_session.go
··· 20 20 "stream.place/streamplace/pkg/bus" 21 21 "stream.place/streamplace/pkg/config" 22 22 "stream.place/streamplace/pkg/livepeer" 23 + "stream.place/streamplace/pkg/localdb" 23 24 "stream.place/streamplace/pkg/log" 24 25 "stream.place/streamplace/pkg/media" 25 26 "stream.place/streamplace/pkg/model" ··· 44 45 lastStatus time.Time 45 46 lastStatusCID *string 46 47 lastOriginTime time.Time 48 + localDB localdb.LocalDB 47 49 48 50 // Channels for background workers 49 51 statusUpdateChan chan struct{} // Signal to update status ··· 121 123 return ss.originUpdateLoop(ctx) 122 124 }) 123 125 124 - ss.Go(ctx, func() error { 125 - return ss.HandleMultistreamTargets(ctx) 126 - }) 126 + if notif.Local { 127 + ss.Go(ctx, func() error { 128 + return ss.HandleMultistreamTargets(ctx) 129 + }) 130 + } 127 131 128 132 for { 129 133 select { ··· 176 180 aqt := aqtime.FromTime(notif.Segment.StartTime) 177 181 ctx = log.WithLogValues(ctx, "segID", notif.Segment.ID, "repoDID", notif.Segment.RepoDID, "timestamp", aqt.FileSafeString()) 178 182 notif.Segment.MediaData.Size = len(notif.Data) 179 - err := ss.mod.CreateSegment(notif.Segment) 183 + err := ss.localDB.CreateSegment(notif.Segment) 180 184 if err != nil { 181 185 return fmt.Errorf("could not add segment to database: %w", err) 182 186 } ··· 290 294 return nil 291 295 } 292 296 defer lock.Unlock() 293 - oldThumb, err := ss.mod.LatestThumbnailForUser(not.Segment.RepoDID) 297 + oldThumb, err := ss.localDB.LatestThumbnailForUser(not.Segment.RepoDID) 294 298 if err != nil { 295 299 return err 296 300 } ··· 309 313 if err != nil { 310 314 return err 311 315 } 312 - thumb := &model.Thumbnail{ 316 + thumb := &localdb.Thumbnail{ 313 317 Format: "jpeg", 314 318 SegmentID: not.Segment.ID, 315 319 } 316 - err = ss.mod.CreateThumbnail(thumb) 320 + err = ss.localDB.CreateThumbnail(thumb) 317 321 if err != nil { 318 322 return err 319 323 }
+3
pkg/gen/gen.go
··· 36 36 streamplace.ModerationPermission{}, 37 37 streamplace.LiveTeleport{}, 38 38 streamplace.LiveRecommendations{}, 39 + streamplace.BadgeIssuance{}, 40 + streamplace.BadgeDisplay{}, 41 + streamplace.BadgeDisplay_BadgeSelection{}, 39 42 ); err != nil { 40 43 panic(err) 41 44 }
+1 -1
pkg/integrations/discord/send-livestream.go
··· 67 67 log.Warn(ctx, "failed to parse URL", "err", err) 68 68 } else { 69 69 suffix = fmt.Sprintf(" on %s!", u.Host) 70 - payload.Embeds[0].URL = fmt.Sprintf("%s/%s", *ls.Url, lsv.Author.Handle) 70 + payload.Embeds[0].URL = *ls.Url 71 71 } 72 72 } 73 73
+139 -11
pkg/linking/linking.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/json" 6 7 "errors" 7 8 "fmt" 9 + "log" 8 10 "net/url" 9 11 10 12 "golang.org/x/net/html" 13 + "stream.place/streamplace/pkg/config" 14 + "stream.place/streamplace/pkg/statedb" 11 15 "stream.place/streamplace/pkg/streamplace" 12 16 ) 13 17 14 18 type Linker struct { 15 19 BaseHTML []byte 20 + sdb *statedb.StatefulDB 21 + cli *config.CLI 16 22 } 17 23 18 - func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { 24 + func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { 19 25 _, err := html.Parse(bytes.NewReader(baseHTML)) 20 26 if err != nil { 21 27 return nil, err 22 28 } 23 29 24 - return &Linker{BaseHTML: baseHTML}, nil 30 + return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil 25 31 } 26 32 27 33 type PageConfig struct { 28 34 Title string 29 35 Metas []MetaTag 30 36 SentryDSN string 37 + Branding []string 31 38 } 32 39 33 40 // Define all meta tags in a structured way ··· 37 44 Content string 38 45 } 39 46 47 + var BrandingAssetList = [...]string{ 48 + "siteTitle", 49 + "siteDescription", 50 + "primaryColor", 51 + "accentColor", 52 + "defaultStreamer", 53 + "mainLogo", 54 + "favicon", 55 + "sidebarBg", 56 + "legalLinks", 57 + } 58 + 59 + // fetch branding assets for a given broadcaster DID 60 + func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { 61 + ret := make([]streamplace.BrandingGetBranding_BrandingAsset, 0) 62 + for _, asset := range BrandingAssetList { 63 + blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) 64 + if err != nil { 65 + // this can probably include a 'record not found' error, in which case we skip 66 + log.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) 67 + continue 68 + } 69 + asset := streamplace.BrandingGetBranding_BrandingAsset{ 70 + Key: blob.Key, 71 + MimeType: blob.MimeType, 72 + } 73 + 74 + if blob.Width != nil { 75 + w := int64(*blob.Width) 76 + asset.Width = &w 77 + } 78 + if blob.Height != nil { 79 + h := int64(*blob.Height) 80 + asset.Height = &h 81 + } 82 + 83 + // process based on mime type 84 + if blob.MimeType == "text/plain" { 85 + str := string(blob.Data) 86 + asset.Data = &str 87 + } else { 88 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) 89 + asset.Url = &url 90 + } 91 + ret = append(ret, asset) 92 + } 93 + 94 + return ret, nil 95 + } 96 + 40 97 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 41 98 if u == nil { 42 99 return nil, errors.New("url is nil") ··· 49 106 return nil, errors.New("livestream view is not a livestream") 50 107 } 51 108 52 - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) 109 + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) 53 110 outURL := u.String() 54 - 55 - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) 56 111 57 112 thumbURL, _ := url.Parse(u.String()) 58 113 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 66 121 // Facebook Meta Tags 67 122 {Type: "property", Key: "og:url", Content: u.String()}, 68 123 {Type: "property", Key: "og:type", Content: "website"}, 69 - {Type: "property", Key: "og:title", Content: titleStr}, 70 124 {Type: "property", Key: "og:description", Content: ls.Title}, 71 125 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 72 126 ··· 74 128 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 75 129 {Type: "property", Key: "twitter:domain", Content: u.Host}, 76 130 {Type: "property", Key: "twitter:url", Content: outURL}, 77 - {Type: "name", Key: "twitter:title", Content: titleStr}, 78 131 {Type: "name", Key: "twitter:description", Content: ls.Title}, 79 132 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 80 133 } 134 + brandingTitle := "streamplace node" 135 + if l.sdb != nil && l.cli != nil { 136 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 137 + if err == nil { 138 + for i := range branding { 139 + val := branding[i] 140 + if val.Key == "siteTitle" && val.Data != nil { 141 + brandingTitle = *val.Data 142 + } 143 + marshalledJson, err := json.Marshal(val) 144 + if err != nil { 145 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 146 + continue 147 + } 148 + metaTags = append(metaTags, MetaTag{ 149 + Type: "name", 150 + Key: "internal-brand:" + val.Key, 151 + Content: string(marshalledJson), 152 + }) 153 + } 154 + } else { 155 + // log but we should not block rendering 156 + fmt.Printf("error fetching branding assets: %v\n", err) 157 + } 158 + } 159 + 160 + // do twitter/og title after 161 + metaTags = append(metaTags, MetaTag{ 162 + Type: "property", 163 + Key: "og:title", 164 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 165 + }) 166 + metaTags = append(metaTags, MetaTag{ 167 + Type: "name", 168 + Key: "twitter:title", 169 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 170 + }) 81 171 82 172 return l.GenerateHTML(ctx, &PageConfig{ 83 - Title: pageTitle, 173 + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), 84 174 Metas: metaTags, 85 175 SentryDSN: sentryDSN, 86 176 }) ··· 103 193 {Type: "property", Key: "og:url", Content: u.String()}, 104 194 {Type: "property", Key: "og:type", Content: "website"}, 105 195 {Type: "property", Key: "og:title", Content: "Stream.place"}, 106 - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 196 + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, 107 197 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 108 198 109 199 // Twitter Meta Tags ··· 111 201 {Type: "property", Key: "twitter:domain", Content: u.Host}, 112 202 {Type: "property", Key: "twitter:url", Content: u.String()}, 113 203 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 114 - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 204 + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, 115 205 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 116 206 } 117 207 208 + brandingTitle := "streamplace node" 209 + if l.sdb != nil && l.cli != nil { 210 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 211 + if err == nil { 212 + for i := range branding { 213 + val := branding[i] 214 + if val.Key == "siteTitle" && val.Data != nil { 215 + brandingTitle = *val.Data 216 + } 217 + marshalledJson, err := json.Marshal(val) 218 + if err != nil { 219 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 220 + continue 221 + } 222 + metaTags = append(metaTags, MetaTag{ 223 + Type: "name", 224 + Key: "internal-brand:" + val.Key, 225 + Content: string(marshalledJson), 226 + }) 227 + } 228 + } else { 229 + // log but we should not block rendering 230 + fmt.Printf("error fetching branding assets: %v\n", err) 231 + } 232 + } 233 + 234 + // do twitter/og title after 235 + metaTags = append(metaTags, MetaTag{ 236 + Type: "property", 237 + Key: "og:title", 238 + Content: brandingTitle, 239 + }) 240 + metaTags = append(metaTags, MetaTag{ 241 + Type: "name", 242 + Key: "twitter:title", 243 + Content: brandingTitle, 244 + }) 245 + 118 246 return l.GenerateHTML(ctx, &PageConfig{ 119 - Title: "Stream.place", 247 + Title: brandingTitle, 120 248 Metas: metaTags, 121 249 SentryDSN: sentryDSN, 122 250 })
+2 -2
pkg/linking/linking_test.go
··· 29 29 30 30 func TestNewLinker(t *testing.T) { 31 31 index := IndexHTML(t) 32 - linker, err := NewLinker(context.Background(), index) 32 + linker, err := NewLinker(context.Background(), index, nil, nil) 33 33 require.NoError(t, err) 34 34 require.NotNil(t, linker) 35 35 } 36 36 37 37 func TestGenerateLinkCard(t *testing.T) { 38 38 index := IndexHTML(t) 39 - linker, err := NewLinker(context.Background(), index) 39 + linker, err := NewLinker(context.Background(), index, nil, nil) 40 40 require.NoError(t, err) 41 41 require.NotNil(t, linker) 42 42
+81
pkg/localdb/localdb.go
··· 1 + package localdb 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "gorm.io/driver/sqlite" 10 + "gorm.io/gorm" 11 + "gorm.io/plugin/prometheus" 12 + "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/log" 14 + ) 15 + 16 + type LocalDB interface { 17 + CreateSegment(segment *Segment) error 18 + MostRecentSegments() ([]Segment, error) 19 + LatestSegmentForUser(user string) (*Segment, error) 20 + LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 21 + FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 22 + CreateThumbnail(thumb *Thumbnail) error 23 + LatestThumbnailForUser(user string) (*Thumbnail, error) 24 + GetSegment(id string) (*Segment, error) 25 + GetExpiredSegments(ctx context.Context) ([]Segment, error) 26 + DeleteSegment(ctx context.Context, id string) error 27 + StartSegmentCleaner(ctx context.Context) error 28 + SegmentCleaner(ctx context.Context) error 29 + } 30 + 31 + type LocalDatabase struct { 32 + DB *gorm.DB 33 + } 34 + 35 + func MakeDB(dbURL string) (LocalDB, error) { 36 + log.Log(context.Background(), "starting database", "dbURL", dbURL) 37 + if strings.HasPrefix(dbURL, "sqlite://") { 38 + dbURL = dbURL[len("sqlite://"):] 39 + } else if dbURL != ":memory:" { 40 + return nil, fmt.Errorf("unsupported database URL (most start with sqlite://): %s", dbURL) 41 + } 42 + dial := sqlite.Open(dbURL) 43 + 44 + db, err := gorm.Open(dial, &gorm.Config{ 45 + SkipDefaultTransaction: true, 46 + TranslateError: true, 47 + Logger: config.GormLogger, 48 + }) 49 + if err != nil { 50 + return nil, fmt.Errorf("error starting database: %w", err) 51 + } 52 + err = db.Exec("PRAGMA journal_mode=WAL;").Error 53 + if err != nil { 54 + return nil, fmt.Errorf("error setting journal mode: %w", err) 55 + } 56 + 57 + err = db.Use(prometheus.New(prometheus.Config{ 58 + DBName: "localdb", 59 + RefreshInterval: 10, 60 + StartServer: false, 61 + })) 62 + if err != nil { 63 + return nil, fmt.Errorf("error using prometheus plugin: %w", err) 64 + } 65 + 66 + sqlDB, err := db.DB() 67 + if err != nil { 68 + return nil, fmt.Errorf("error getting database: %w", err) 69 + } 70 + sqlDB.SetMaxOpenConns(1) 71 + for _, model := range []any{ 72 + Segment{}, 73 + Thumbnail{}, 74 + } { 75 + err = db.AutoMigrate(model) 76 + if err != nil { 77 + return nil, err 78 + } 79 + } 80 + return &LocalDatabase{DB: db}, nil 81 + }
+410
pkg/localdb/segment.go
··· 1 + package localdb 2 + 3 + import ( 4 + "context" 5 + "database/sql/driver" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "time" 10 + 11 + "gorm.io/gorm" 12 + "stream.place/streamplace/pkg/aqtime" 13 + "stream.place/streamplace/pkg/log" 14 + "stream.place/streamplace/pkg/streamplace" 15 + ) 16 + 17 + type SegmentMediadataVideo struct { 18 + Width int `json:"width"` 19 + Height int `json:"height"` 20 + FPSNum int `json:"fpsNum"` 21 + FPSDen int `json:"fpsDen"` 22 + BFrames bool `json:"bframes"` 23 + } 24 + 25 + type SegmentMediadataAudio struct { 26 + Rate int `json:"rate"` 27 + Channels int `json:"channels"` 28 + } 29 + 30 + type SegmentMediaData struct { 31 + Video []*SegmentMediadataVideo `json:"video"` 32 + Audio []*SegmentMediadataAudio `json:"audio"` 33 + Duration int64 `json:"duration"` 34 + Size int `json:"size"` 35 + } 36 + 37 + // Scan scan value into Jsonb, implements sql.Scanner interface 38 + func (j *SegmentMediaData) Scan(value any) error { 39 + bytes, ok := value.([]byte) 40 + if !ok { 41 + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) 42 + } 43 + 44 + result := SegmentMediaData{} 45 + err := json.Unmarshal(bytes, &result) 46 + *j = SegmentMediaData(result) 47 + return err 48 + } 49 + 50 + // Value return json value, implement driver.Valuer interface 51 + func (j SegmentMediaData) Value() (driver.Value, error) { 52 + return json.Marshal(j) 53 + } 54 + 55 + // ContentRights represents content rights and attribution information 56 + type ContentRights struct { 57 + CopyrightNotice *string `json:"copyrightNotice,omitempty"` 58 + CopyrightYear *int64 `json:"copyrightYear,omitempty"` 59 + Creator *string `json:"creator,omitempty"` 60 + CreditLine *string `json:"creditLine,omitempty"` 61 + License *string `json:"license,omitempty"` 62 + } 63 + 64 + // Scan scan value into ContentRights, implements sql.Scanner interface 65 + func (c *ContentRights) Scan(value any) error { 66 + if value == nil { 67 + *c = ContentRights{} 68 + return nil 69 + } 70 + bytes, ok := value.([]byte) 71 + if !ok { 72 + return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value)) 73 + } 74 + 75 + result := ContentRights{} 76 + err := json.Unmarshal(bytes, &result) 77 + *c = ContentRights(result) 78 + return err 79 + } 80 + 81 + // Value return json value, implement driver.Valuer interface 82 + func (c ContentRights) Value() (driver.Value, error) { 83 + return json.Marshal(c) 84 + } 85 + 86 + // DistributionPolicy represents distribution policy information 87 + type DistributionPolicy struct { 88 + DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 + } 90 + 91 + // Scan scan value into DistributionPolicy, implements sql.Scanner interface 92 + func (d *DistributionPolicy) Scan(value any) error { 93 + if value == nil { 94 + *d = DistributionPolicy{} 95 + return nil 96 + } 97 + bytes, ok := value.([]byte) 98 + if !ok { 99 + return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value)) 100 + } 101 + 102 + result := DistributionPolicy{} 103 + err := json.Unmarshal(bytes, &result) 104 + *d = DistributionPolicy(result) 105 + return err 106 + } 107 + 108 + // Value return json value, implement driver.Valuer interface 109 + func (d DistributionPolicy) Value() (driver.Value, error) { 110 + return json.Marshal(d) 111 + } 112 + 113 + // ContentWarningsSlice is a custom type for storing content warnings as JSON in the database 114 + type ContentWarningsSlice []string 115 + 116 + // Scan scan value into ContentWarningsSlice, implements sql.Scanner interface 117 + func (c *ContentWarningsSlice) Scan(value any) error { 118 + if value == nil { 119 + *c = ContentWarningsSlice{} 120 + return nil 121 + } 122 + bytes, ok := value.([]byte) 123 + if !ok { 124 + return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value)) 125 + } 126 + 127 + result := ContentWarningsSlice{} 128 + err := json.Unmarshal(bytes, &result) 129 + *c = ContentWarningsSlice(result) 130 + return err 131 + } 132 + 133 + // Value return json value, implement driver.Valuer interface 134 + func (c ContentWarningsSlice) Value() (driver.Value, error) { 135 + return json.Marshal(c) 136 + } 137 + 138 + type Segment struct { 139 + ID string `json:"id" gorm:"primaryKey"` 140 + SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` 141 + StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` 142 + RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` 143 + Title string `json:"title"` 144 + Size int `json:"size" gorm:"column:size"` 145 + MediaData *SegmentMediaData `json:"mediaData,omitempty"` 146 + ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"` 147 + ContentRights *ContentRights `json:"contentRights,omitempty"` 148 + DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 149 + DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 150 + } 151 + 152 + func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { 153 + aqt := aqtime.FromTime(s.StartTime) 154 + if s.MediaData == nil { 155 + return nil, fmt.Errorf("media data is nil") 156 + } 157 + if len(s.MediaData.Video) == 0 || s.MediaData.Video[0] == nil { 158 + return nil, fmt.Errorf("video data is nil") 159 + } 160 + if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 161 + return nil, fmt.Errorf("audio data is nil") 162 + } 163 + duration := s.MediaData.Duration 164 + sizei64 := int64(s.Size) 165 + 166 + // Convert model metadata to streamplace metadata 167 + var contentRights *streamplace.MetadataContentRights 168 + if s.ContentRights != nil { 169 + contentRights = &streamplace.MetadataContentRights{ 170 + CopyrightNotice: s.ContentRights.CopyrightNotice, 171 + CopyrightYear: s.ContentRights.CopyrightYear, 172 + Creator: s.ContentRights.Creator, 173 + CreditLine: s.ContentRights.CreditLine, 174 + License: s.ContentRights.License, 175 + } 176 + } 177 + 178 + var contentWarnings *streamplace.MetadataContentWarnings 179 + if len(s.ContentWarnings) > 0 { 180 + contentWarnings = &streamplace.MetadataContentWarnings{ 181 + Warnings: []string(s.ContentWarnings), 182 + } 183 + } 184 + 185 + var distributionPolicy *streamplace.MetadataDistributionPolicy 186 + if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 187 + distributionPolicy = &streamplace.MetadataDistributionPolicy{ 188 + DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 189 + } 190 + } 191 + 192 + return &streamplace.Segment{ 193 + LexiconTypeID: "place.stream.segment", 194 + Creator: s.RepoDID, 195 + Id: s.ID, 196 + SigningKey: s.SigningKeyDID, 197 + StartTime: string(aqt), 198 + Duration: &duration, 199 + Size: &sizei64, 200 + ContentRights: contentRights, 201 + ContentWarnings: contentWarnings, 202 + DistributionPolicy: distributionPolicy, 203 + Video: []*streamplace.Segment_Video{ 204 + { 205 + Codec: "h264", 206 + Width: int64(s.MediaData.Video[0].Width), 207 + Height: int64(s.MediaData.Video[0].Height), 208 + Framerate: &streamplace.Segment_Framerate{ 209 + Num: int64(s.MediaData.Video[0].FPSNum), 210 + Den: int64(s.MediaData.Video[0].FPSDen), 211 + }, 212 + Bframes: &s.MediaData.Video[0].BFrames, 213 + }, 214 + }, 215 + Audio: []*streamplace.Segment_Audio{ 216 + { 217 + Codec: "opus", 218 + Rate: int64(s.MediaData.Audio[0].Rate), 219 + Channels: int64(s.MediaData.Audio[0].Channels), 220 + }, 221 + }, 222 + }, nil 223 + } 224 + 225 + func (m *LocalDatabase) CreateSegment(seg *Segment) error { 226 + err := m.DB.Model(Segment{}).Create(seg).Error 227 + if err != nil { 228 + return err 229 + } 230 + return nil 231 + } 232 + 233 + // should return the most recent segment for each user, ordered by most recent first 234 + // only includes segments from the last 30 seconds 235 + func (m *LocalDatabase) MostRecentSegments() ([]Segment, error) { 236 + var segments []Segment 237 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 238 + 239 + err := m.DB.Table("segments"). 240 + Select("segments.*"). 241 + Where("start_time > ?", thirtySecondsAgo.UTC()). 242 + Order("start_time DESC"). 243 + Find(&segments).Error 244 + if err != nil { 245 + return nil, err 246 + } 247 + if segments == nil { 248 + return []Segment{}, nil 249 + } 250 + 251 + segmentMap := make(map[string]Segment) 252 + for _, seg := range segments { 253 + prev, ok := segmentMap[seg.RepoDID] 254 + if !ok { 255 + segmentMap[seg.RepoDID] = seg 256 + } else { 257 + if seg.StartTime.After(prev.StartTime) { 258 + segmentMap[seg.RepoDID] = seg 259 + } 260 + } 261 + } 262 + 263 + filteredSegments := []Segment{} 264 + for _, seg := range segmentMap { 265 + filteredSegments = append(filteredSegments, seg) 266 + } 267 + 268 + return filteredSegments, nil 269 + } 270 + 271 + func (m *LocalDatabase) LatestSegmentForUser(user string) (*Segment, error) { 272 + var seg Segment 273 + err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 274 + if err != nil { 275 + return nil, err 276 + } 277 + return &seg, nil 278 + } 279 + 280 + func (m *LocalDatabase) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 281 + if len(repoDIDs) == 0 { 282 + return []string{}, nil 283 + } 284 + 285 + thirtySecondsAgo := time.Now().Add(-30 * time.Second) 286 + 287 + var liveDIDs []string 288 + 289 + err := m.DB.Table("segments"). 290 + Select("DISTINCT repo_did"). 291 + Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 292 + Pluck("repo_did", &liveDIDs).Error 293 + 294 + if err != nil { 295 + return nil, err 296 + } 297 + 298 + return liveDIDs, nil 299 + } 300 + 301 + func (m *LocalDatabase) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 302 + var segs []Segment 303 + if before == nil { 304 + later := time.Now().Add(1000 * time.Hour) 305 + before = &later 306 + } 307 + if after == nil { 308 + earlier := time.Time{} 309 + after = &earlier 310 + } 311 + err := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()).Order("start_time DESC").Limit(limit).Find(&segs).Error 312 + if err != nil { 313 + return nil, err 314 + } 315 + return segs, nil 316 + } 317 + 318 + func (m *LocalDatabase) GetSegment(id string) (*Segment, error) { 319 + var seg Segment 320 + 321 + err := m.DB.Model(&Segment{}). 322 + Preload("Repo"). 323 + Where("id = ?", id). 324 + First(&seg).Error 325 + 326 + if errors.Is(err, gorm.ErrRecordNotFound) { 327 + return nil, nil 328 + } 329 + if err != nil { 330 + return nil, err 331 + } 332 + 333 + return &seg, nil 334 + } 335 + 336 + func (m *LocalDatabase) GetExpiredSegments(ctx context.Context) ([]Segment, error) { 337 + 338 + var expiredSegments []Segment 339 + now := time.Now() 340 + err := m.DB. 341 + Where("delete_after IS NOT NULL AND delete_after < ?", now.UTC()). 342 + Find(&expiredSegments).Error 343 + if err != nil { 344 + return nil, err 345 + } 346 + 347 + return expiredSegments, nil 348 + } 349 + 350 + func (m *LocalDatabase) DeleteSegment(ctx context.Context, id string) error { 351 + return m.DB.Delete(&Segment{}, "id = ?", id).Error 352 + } 353 + 354 + func (m *LocalDatabase) StartSegmentCleaner(ctx context.Context) error { 355 + err := m.SegmentCleaner(ctx) 356 + if err != nil { 357 + return err 358 + } 359 + ticker := time.NewTicker(1 * time.Minute) 360 + defer ticker.Stop() 361 + 362 + for { 363 + select { 364 + case <-ctx.Done(): 365 + return nil 366 + case <-ticker.C: 367 + err := m.SegmentCleaner(ctx) 368 + if err != nil { 369 + log.Error(ctx, "Failed to clean segments", "error", err) 370 + } 371 + } 372 + } 373 + } 374 + 375 + func (m *LocalDatabase) SegmentCleaner(ctx context.Context) error { 376 + // Calculate the cutoff time (10 minutes ago) 377 + cutoffTime := aqtime.FromTime(time.Now().Add(-10 * time.Minute)).Time() 378 + 379 + // Find all unique repo_did values 380 + var repoDIDs []string 381 + if err := m.DB.Model(&Segment{}).Distinct("repo_did").Pluck("repo_did", &repoDIDs).Error; err != nil { 382 + log.Error(ctx, "Failed to get unique repo_dids for segment cleaning", "error", err) 383 + return err 384 + } 385 + 386 + // For each user, keep their last 10 segments and delete older ones 387 + for _, repoDID := range repoDIDs { 388 + // Get IDs of the last 10 segments for this user 389 + var keepSegmentIDs []string 390 + if err := m.DB.Model(&Segment{}). 391 + Where("repo_did = ?", repoDID). 392 + Order("start_time DESC"). 393 + Limit(10). 394 + Pluck("id", &keepSegmentIDs).Error; err != nil { 395 + log.Error(ctx, "Failed to get segment IDs to keep", "repo_did", repoDID, "error", err) 396 + return err 397 + } 398 + 399 + // Delete old segments except the ones we want to keep 400 + result := m.DB.Where("repo_did = ? AND start_time < ? AND id NOT IN ?", 401 + repoDID, cutoffTime, keepSegmentIDs).Delete(&Segment{}) 402 + 403 + if result.Error != nil { 404 + log.Error(ctx, "Failed to clean old segments", "repo_did", repoDID, "error", result.Error) 405 + } else if result.RowsAffected > 0 { 406 + log.Log(ctx, "Cleaned old segments", "repo_did", repoDID, "count", result.RowsAffected) 407 + } 408 + } 409 + return nil 410 + }
+59
pkg/localdb/segment_test.go
··· 1 + package localdb 2 + 3 + import ( 4 + "fmt" 5 + "sync" 6 + "testing" 7 + "time" 8 + 9 + "github.com/stretchr/testify/require" 10 + "stream.place/streamplace/pkg/config" 11 + ) 12 + 13 + func TestSegmentPerf(t *testing.T) { 14 + config.DisableSQLLogging() 15 + // dburl := filepath.Join(t.TempDir(), "test.db") 16 + db, err := MakeDB(":memory:") 17 + require.NoError(t, err) 18 + // Create a ldb instance 19 + ldb := db.(*LocalDatabase) 20 + t.Cleanup(func() { 21 + // os.Remove(dburl) 22 + }) 23 + 24 + defer config.EnableSQLLogging() 25 + // Create 250000 segments with timestamps 1 hour ago, each one second apart 26 + wg := sync.WaitGroup{} 27 + segCount := 250000 28 + wg.Add(segCount) 29 + baseTime := time.Now() 30 + for i := 0; i < segCount; i++ { 31 + segment := &Segment{ 32 + ID: fmt.Sprintf("segment-%d", i), 33 + RepoDID: "did:plc:test123", 34 + StartTime: baseTime.Add(-time.Duration(i) * time.Second).UTC(), 35 + } 36 + go func() { 37 + defer wg.Done() 38 + err = ldb.DB.Create(segment).Error 39 + require.NoError(t, err) 40 + }() 41 + } 42 + wg.Wait() 43 + 44 + startTime := time.Now() 45 + wg = sync.WaitGroup{} 46 + runs := 1000 47 + wg.Add(runs) 48 + for i := 0; i < runs; i++ { 49 + go func() { 50 + defer wg.Done() 51 + _, err := ldb.MostRecentSegments() 52 + require.NoError(t, err) 53 + // require.Len(t, segments, 1) 54 + }() 55 + } 56 + wg.Wait() 57 + fmt.Printf("Time taken: %s\n", time.Since(startTime)) 58 + require.Less(t, time.Since(startTime), 10*time.Second) 59 + }
+60
pkg/localdb/thumbnail.go
··· 1 + package localdb 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/google/uuid" 7 + ) 8 + 9 + type Thumbnail struct { 10 + ID string `json:"id" gorm:"primaryKey"` 11 + Format string `json:"format"` 12 + SegmentID string `json:"segmentId" gorm:"index"` 13 + Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 + } 15 + 16 + func (m *LocalDatabase) CreateThumbnail(thumb *Thumbnail) error { 17 + uu, err := uuid.NewV7() 18 + if err != nil { 19 + return err 20 + } 21 + if thumb.SegmentID == "" { 22 + return fmt.Errorf("segmentID is required") 23 + } 24 + thumb.ID = uu.String() 25 + err = m.DB.Model(Thumbnail{}).Create(thumb).Error 26 + if err != nil { 27 + return err 28 + } 29 + return nil 30 + } 31 + 32 + // return the most recent thumbnail for a user 33 + func (m *LocalDatabase) LatestThumbnailForUser(user string) (*Thumbnail, error) { 34 + var thumbnail Thumbnail 35 + 36 + res := m.DB.Table("thumbnails AS t"). 37 + Select("t.*"). 38 + Joins("JOIN segments AS s ON t.segment_id = s.id"). 39 + Where("s.repo_did = ?", user). 40 + Order("s.start_time DESC"). 41 + Limit(1). 42 + Scan(&thumbnail) 43 + 44 + if res.RowsAffected == 0 { 45 + return nil, nil 46 + } 47 + if res.Error != nil { 48 + return nil, res.Error 49 + } 50 + 51 + var seg Segment 52 + err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 53 + if err != nil { 54 + return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 55 + } 56 + 57 + thumbnail.Segment = seg 58 + 59 + return &thumbnail, nil 60 + }
+3 -3
pkg/media/clip_user.go
··· 10 10 11 11 "stream.place/streamplace/pkg/aqtime" 12 12 "stream.place/streamplace/pkg/config" 13 - "stream.place/streamplace/pkg/model" 13 + "stream.place/streamplace/pkg/localdb" 14 14 ) 15 15 16 - func ClipUser(ctx context.Context, mod model.Model, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { 17 - segments, err := mod.LatestSegmentsForUser(user, -1, before, after) 16 + func ClipUser(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, user string, writer io.Writer, before *time.Time, after *time.Time) error { 17 + segments, err := localDB.LatestSegmentsForUser(user, -1, before, after) 18 18 if err != nil { 19 19 return fmt.Errorf("unable to get segments: %w", err) 20 20 }
+11 -8
pkg/media/media.go
··· 21 21 c2patypes "stream.place/streamplace/pkg/c2patypes" 22 22 "stream.place/streamplace/pkg/config" 23 23 "stream.place/streamplace/pkg/gstinit" 24 + "stream.place/streamplace/pkg/localdb" 24 25 "stream.place/streamplace/pkg/model" 25 26 "stream.place/streamplace/pkg/streamplace" 26 27 ··· 51 52 atsync *atproto.ATProtoSynchronizer 52 53 webrtcAPI *webrtc.API 53 54 webrtcConfig webrtc.Configuration 55 + localDB localdb.LocalDB 54 56 } 55 57 56 58 type NewSegmentNotification struct { 57 - Segment *model.Segment 59 + Segment *localdb.Segment 58 60 Data []byte 59 61 Metadata *SegmentMetadata 60 62 Local bool ··· 65 67 return SelfTest(ctx) 66 68 } 67 69 68 - func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, mod model.Model, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer) (*MediaManager, error) { 70 + func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer, mod model.Model, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, ldb localdb.LocalDB) (*MediaManager, error) { 69 71 gstinit.InitGST() 70 72 err := SelfTest(ctx) 71 73 if err != nil { ··· 127 129 atsync: atsync, 128 130 webrtcAPI: api, 129 131 webrtcConfig: config, 132 + localDB: ldb, 130 133 }, nil 131 134 } 132 135 ··· 190 193 Title string 191 194 Creator string 192 195 ContentWarnings []string 193 - ContentRights *model.ContentRights 194 - DistributionPolicy *model.DistributionPolicy 196 + ContentRights *localdb.ContentRights 197 + DistributionPolicy *localdb.DistributionPolicy 195 198 MetadataConfiguration *streamplace.MetadataConfiguration 196 199 Livestream *streamplace.Livestream 197 200 } ··· 312 315 } 313 316 314 317 // extractContentRights extracts content rights from the C2PA manifest 315 - func extractContentRights(mani *c2patypes.Manifest) *model.ContentRights { 318 + func extractContentRights(mani *c2patypes.Manifest) *localdb.ContentRights { 316 319 ass := findAssertion(mani, StreamplaceMetadata) 317 320 if ass == nil { 318 321 return nil ··· 323 326 return nil 324 327 } 325 328 326 - rights := &model.ContentRights{} 329 + rights := &localdb.ContentRights{} 327 330 328 331 // Extract copyright notice 329 332 if notice, ok := data["dc:rights"]; ok { ··· 375 378 } 376 379 377 380 // extractDistributionPolicy extracts distribution policy from the C2PA manifest 378 - func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *model.DistributionPolicy { 381 + func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *localdb.DistributionPolicy { 379 382 metadataConfig := extractMetadataConfiguration(mani) 380 383 if metadataConfig == nil { 381 384 return nil ··· 392 395 // deleteAfter contains an offset in seconds from creation time 393 396 deleteAfterSeconds := *metadataConfig.DistributionPolicy.DeleteAfter 394 397 395 - return &model.DistributionPolicy{ 398 + return &localdb.DistributionPolicy{ 396 399 DeleteAfterSeconds: &deleteAfterSeconds, 397 400 } 398 401 }
+9 -9
pkg/media/media_data_parser.go
··· 13 13 "github.com/go-gst/go-gst/gst" 14 14 "github.com/go-gst/go-gst/gst/app" 15 15 "go.opentelemetry.io/otel" 16 + "stream.place/streamplace/pkg/localdb" 16 17 "stream.place/streamplace/pkg/log" 17 - "stream.place/streamplace/pkg/model" 18 18 ) 19 19 20 20 func padProbeEmpty(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn { 21 21 return gst.PadProbeOK 22 22 } 23 23 24 - func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*model.SegmentMediaData, error) { 24 + func ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*localdb.SegmentMediaData, error) { 25 25 ctx, span := otel.Tracer("signer").Start(ctx, "ParseSegmentMediaData") 26 26 defer span.End() 27 27 ctx = log.WithLogValues(ctx, "GStreamerFunc", "ParseSegmentMediaData") ··· 40 40 return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 41 41 } 42 42 43 - var videoMetadata *model.SegmentMediadataVideo 44 - var audioMetadata *model.SegmentMediadataAudio 43 + var videoMetadata *localdb.SegmentMediadataVideo 44 + var audioMetadata *localdb.SegmentMediadataAudio 45 45 46 46 appsrc, err := pipeline.GetElementByName("appsrc") 47 47 if err != nil { ··· 118 118 name := structure.Name() 119 119 120 120 if name[:5] == "video" { 121 - videoMetadata = &model.SegmentMediadataVideo{} 121 + videoMetadata = &localdb.SegmentMediadataVideo{} 122 122 // Get some common video properties 123 123 widthVal, _ := structure.GetValue("width") 124 124 heightVal, _ := structure.GetValue("height") ··· 147 147 } 148 148 149 149 if name[:5] == "audio" { 150 - audioMetadata = &model.SegmentMediadataAudio{} 150 + audioMetadata = &localdb.SegmentMediadataAudio{} 151 151 // Get some common audio properties 152 152 rateVal, _ := structure.GetValue("rate") 153 153 channelsVal, _ := structure.GetValue("channels") ··· 275 275 276 276 videoMetadata.BFrames = hasBFrames 277 277 278 - meta := &model.SegmentMediaData{ 279 - Video: []*model.SegmentMediadataVideo{videoMetadata}, 280 - Audio: []*model.SegmentMediadataAudio{audioMetadata}, 278 + meta := &localdb.SegmentMediaData{ 279 + Video: []*localdb.SegmentMediadataVideo{videoMetadata}, 280 + Audio: []*localdb.SegmentMediadataAudio{audioMetadata}, 281 281 } 282 282 283 283 ok, dur := pipeline.QueryDuration(gst.FormatTime)
+4 -1
pkg/media/media_test.go
··· 11 11 "stream.place/streamplace/pkg/bus" 12 12 "stream.place/streamplace/pkg/config" 13 13 ct "stream.place/streamplace/pkg/config/configtesting" 14 + "stream.place/streamplace/pkg/localdb" 14 15 "stream.place/streamplace/pkg/model" 15 16 "stream.place/streamplace/pkg/statedb" 16 17 ) ··· 23 24 24 25 func getStaticTestMediaManager(t *testing.T) (*MediaManager, MediaSigner) { 25 26 mod, err := model.MakeDB(":memory:") 27 + require.NoError(t, err) 28 + ldb, err := localdb.MakeDB(":memory:") 26 29 require.NoError(t, err) 27 30 // signer, err := c2pa.MakeStaticSigner(eip712test.KeyBytes) 28 31 require.NoError(t, err) ··· 42 45 StatefulDB: statedb, 43 46 Bus: bus.NewBus(), 44 47 } 45 - mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync) 48 + mm, err := MakeMediaManager(context.Background(), cli, nil, mod, bus.NewBus(), atsync, ldb) 46 49 require.NoError(t, err) 47 50 // ms, err := MakeMediaSigner(context.Background(), cli, "test-person", signer) 48 51 // require.NoError(t, err)
+96 -111
pkg/media/rtcrec_test.go
··· 1 1 package media 2 2 3 - import ( 4 - "context" 5 - "os" 6 - "testing" 3 + // var RTCRecTestCases = []struct { 4 + // name string 5 + // fatalErrors bool 6 + // fixture string 7 + // expectedSegmentsMin int 8 + // expectedSegmentsMax int 9 + // }{ 10 + // { 11 + // name: "IntermittentTracks", 12 + // fatalErrors: false, 13 + // fixture: getFixture("intermittent-tracks.cbor"), 14 + // expectedSegmentsMin: 10, 15 + // expectedSegmentsMax: 15, 16 + // }, 17 + // { 18 + // name: "SegmentConvergenceIssues", 19 + // fatalErrors: true, 20 + // fixture: remote.RemoteFixture("6a1fb84e3c23405fc53161f59d5b837839c4889fc1a96533c82fb44fafc51d27/2025-11-14T22-41-20-399Z.cbor"), 21 + // expectedSegmentsMin: 1, 22 + // expectedSegmentsMax: 10, 23 + // }, 24 + // { 25 + // name: "NekomimiPet", 26 + // fixture: remote.RemoteFixture("91176de4b92fb4c8e84116bd2be0070e96f964fcb8e127da4bfa7020317f4195/nekomimi.pet.rtcrec.cbor"), 27 + // fatalErrors: true, 28 + // expectedSegmentsMin: 29, 29 + // expectedSegmentsMax: 29, 30 + // }, 31 + // } 7 32 8 - "github.com/cenkalti/backoff/v5" 9 - "github.com/pion/webrtc/v4" 10 - "github.com/stretchr/testify/require" 11 - "go.uber.org/goleak" 12 - "stream.place/streamplace/pkg/config" 13 - "stream.place/streamplace/pkg/crypto/spkey" 14 - "stream.place/streamplace/pkg/rtcrec" 15 - "stream.place/streamplace/test/remote" 16 - ) 33 + // func TestRTCRecording(t *testing.T) { 17 34 18 - var RTCRecTestCases = []struct { 19 - name string 20 - fatalErrors bool 21 - fixture string 22 - expectedSegmentsMin int 23 - expectedSegmentsMax int 24 - }{ 25 - { 26 - name: "IntermittentTracks", 27 - fatalErrors: false, 28 - fixture: getFixture("intermittent-tracks.cbor"), 29 - expectedSegmentsMin: 10, 30 - expectedSegmentsMax: 15, 31 - }, 32 - { 33 - name: "SegmentConvergenceIssues", 34 - fatalErrors: true, 35 - fixture: remote.RemoteFixture("6a1fb84e3c23405fc53161f59d5b837839c4889fc1a96533c82fb44fafc51d27/2025-11-14T22-41-20-399Z.cbor"), 36 - expectedSegmentsMin: 1, 37 - expectedSegmentsMax: 10, 38 - }, 39 - { 40 - name: "NekomimiPet", 41 - fixture: remote.RemoteFixture("91176de4b92fb4c8e84116bd2be0070e96f964fcb8e127da4bfa7020317f4195/nekomimi.pet.rtcrec.cbor"), 42 - fatalErrors: true, 43 - expectedSegmentsMin: 29, 44 - expectedSegmentsMax: 29, 45 - }, 46 - } 35 + // previous := FatalSegmentationErrors 36 + // defer func() { 37 + // FatalSegmentationErrors = previous 38 + // }() 39 + // // ctx := context.Background() 40 + // // mm, ms := getStaticTestMediaManager(t) 41 + // for _, testCase := range RTCRecTestCases { 42 + // t.Run(testCase.name, func(t *testing.T) { 43 + // withNoGSTLeaks(t, func() { 44 + // ctx := context.Background() 45 + // dir, err := os.MkdirTemp("", "rtcrec-test-*") 46 + // require.NoError(t, err) 47 + // defer os.RemoveAll(dir) 48 + // cli := &config.CLI{} 49 + // fs := cli.NewFlagSet("rtcrec-test") 50 + // err = cli.Parse(fs, []string{ 51 + // "--data-dir", dir, 52 + // "-wide-open=true", 53 + // "--segment-debug-dir", "/home/iameli/testvids/nekomimi.pet", 54 + // }) 55 + // require.NoError(t, err) 56 + // mm, err := MakeMediaManager(context.Background(), cli, nil, nil, nil, nil) 57 + // require.NoError(t, err) 58 + // priv, pub, err := spkey.GenerateStreamKey() 59 + // require.NoError(t, err) 60 + // signer, err := spkey.KeyToSigner(priv) 61 + // require.NoError(t, err) 62 + // mediaSigner, err := MakeMediaSigner(ctx, cli, pub.DIDKey(), signer, nil) 63 + // require.NoError(t, err) 47 64 48 - func TestRTCRecording(t *testing.T) { 65 + // segsub := mm.NewSegment() 66 + // segCount := 0 67 + // go func() { 68 + // for range segsub { 69 + // segCount++ 70 + // } 71 + // }() 49 72 50 - previous := FatalSegmentationErrors 51 - defer func() { 52 - FatalSegmentationErrors = previous 53 - }() 54 - // ctx := context.Background() 55 - // mm, ms := getStaticTestMediaManager(t) 56 - for _, testCase := range RTCRecTestCases { 57 - t.Run(testCase.name, func(t *testing.T) { 58 - withNoGSTLeaks(t, func() { 59 - ctx := context.Background() 60 - dir, err := os.MkdirTemp("", "rtcrec-test-*") 61 - require.NoError(t, err) 62 - defer os.RemoveAll(dir) 63 - cli := &config.CLI{} 64 - fs := cli.NewFlagSet("rtcrec-test") 65 - err = cli.Parse(fs, []string{ 66 - "--data-dir", dir, 67 - "-wide-open=true", 68 - "--segment-debug-dir", "/home/iameli/testvids/nekomimi.pet", 69 - }) 70 - require.NoError(t, err) 71 - mm, err := MakeMediaManager(context.Background(), cli, nil, nil, nil, nil) 72 - require.NoError(t, err) 73 - priv, pub, err := spkey.GenerateStreamKey() 74 - require.NoError(t, err) 75 - signer, err := spkey.KeyToSigner(priv) 76 - require.NoError(t, err) 77 - mediaSigner, err := MakeMediaSigner(ctx, cli, pub.DIDKey(), signer, nil) 78 - require.NoError(t, err) 73 + // cur := goleak.IgnoreCurrent() 74 + // defer goleak.VerifyNone(t, cur) 79 75 80 - segsub := mm.NewSegment() 81 - segCount := 0 82 - go func() { 83 - for range segsub { 84 - segCount++ 85 - } 86 - }() 76 + // FatalSegmentationErrors = testCase.fatalErrors 77 + // fd, err := os.Open(testCase.fixture) 78 + // require.NoError(t, err) 79 + // defer fd.Close() 80 + // pc, err := rtcrec.NewReplayPeerConnection(ctx, fd) 81 + // require.NoError(t, err) 82 + // done := make(chan error) 83 + // _, err = mm.WebRTCIngest(ctx, &webrtc.SessionDescription{SDP: "placeholder"}, mediaSigner, pc, done) 84 + // require.NoError(t, err) 85 + // // fmt.Println(answer.SDP) 86 + // <-done 87 87 88 - cur := goleak.IgnoreCurrent() 89 - defer goleak.VerifyNone(t, cur) 88 + // // the segment getting ingested is ever so slightly after the done, which doesn't matter except in tests, just do a backoff for checking 89 + // ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 90 + // defer ticker.Stop() 91 + // for i := 0; i < 10; i++ { 92 + // if segCount >= testCase.expectedSegmentsMin { 93 + // break 94 + // } 95 + // if i < 9 { 96 + // <-ticker.C 97 + // } 98 + // } 99 + // require.GreaterOrEqual(t, segCount, testCase.expectedSegmentsMin) 100 + // require.LessOrEqual(t, segCount, testCase.expectedSegmentsMax) 101 + // }) 102 + // }) 103 + // } 90 104 91 - FatalSegmentationErrors = testCase.fatalErrors 92 - fd, err := os.Open(testCase.fixture) 93 - require.NoError(t, err) 94 - defer fd.Close() 95 - pc, err := rtcrec.NewReplayPeerConnection(ctx, fd) 96 - require.NoError(t, err) 97 - done := make(chan error) 98 - _, err = mm.WebRTCIngest(ctx, &webrtc.SessionDescription{SDP: "placeholder"}, mediaSigner, pc, done) 99 - require.NoError(t, err) 100 - // fmt.Println(answer.SDP) 101 - <-done 102 - 103 - // the segment getting ingested is ever so slightly after the done, which doesn't matter except in tests, just do a backoff for checking 104 - ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) 105 - defer ticker.Stop() 106 - for i := 0; i < 10; i++ { 107 - if segCount >= testCase.expectedSegmentsMin { 108 - break 109 - } 110 - if i < 9 { 111 - <-ticker.C 112 - } 113 - } 114 - require.GreaterOrEqual(t, segCount, testCase.expectedSegmentsMin) 115 - require.LessOrEqual(t, segCount, testCase.expectedSegmentsMax) 116 - }) 117 - }) 118 - } 119 - 120 - } 105 + // }
+2 -1
pkg/media/rtmp_push.go
··· 29 29 pipelineSlice := []string{ 30 30 "flvmux name=muxer ! rtmp2sink name=rtmp2sink", 31 31 "h264parse name=videoparse ! muxer.video", 32 - "opusparse name=audioparse ! opusdec ! fdkaacenc ! muxer.audio", 32 + "opusparse name=audioparse ! opusdec ! audioresample ! fdkaacenc ! muxer.audio", 33 33 } 34 34 35 35 pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) ··· 237 237 } 238 238 239 239 func (mm *MediaManager) runForwarder(ctx context.Context, dest string, dial func(destHost string) (net.Conn, error)) (string, error) { 240 + ctx = log.WithLogValues(ctx, "mediafunc", "runForwarder") 240 241 // Parse the destination URL to extract host and port 241 242 destURL, err := url.Parse(dest) 242 243 if err != nil {
+5 -5
pkg/media/validate.go
··· 18 18 "stream.place/streamplace/pkg/constants" 19 19 "stream.place/streamplace/pkg/crypto/signers" 20 20 "stream.place/streamplace/pkg/iroh/generated/iroh_streamplace" 21 + "stream.place/streamplace/pkg/localdb" 21 22 "stream.place/streamplace/pkg/log" 22 - "stream.place/streamplace/pkg/model" 23 23 ) 24 24 25 25 type ManifestAndCert struct { ··· 47 47 48 48 label := manifest.Label 49 49 if label != nil && mm.model != nil { 50 - oldSeg, err := mm.model.GetSegment(*label) 50 + oldSeg, err := mm.localDB.GetSegment(*label) 51 51 if err != nil { 52 52 return fmt.Errorf("failed to get old segment: %w", err) 53 53 } ··· 117 117 expiryTime := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DeleteAfterSeconds) * time.Second) 118 118 deleteAfter = &expiryTime 119 119 } 120 - seg := &model.Segment{ 120 + seg := &localdb.Segment{ 121 121 ID: *label, 122 122 SigningKeyDID: signingKeyDID, 123 123 RepoDID: repoDID, ··· 125 125 Title: meta.Title, 126 126 Size: len(buf), 127 127 MediaData: mediaData, 128 - ContentWarnings: model.ContentWarningsSlice(meta.ContentWarnings), 128 + ContentWarnings: localdb.ContentWarningsSlice(meta.ContentWarnings), 129 129 ContentRights: meta.ContentRights, 130 130 DistributionPolicy: meta.DistributionPolicy, 131 131 DeleteAfter: deleteAfter, ··· 205 205 type ValidationResult struct { 206 206 Pub *atcrypto.PublicKeyK256 207 207 Meta *SegmentMetadata 208 - MediaData *model.SegmentMediaData 208 + MediaData *localdb.SegmentMediaData 209 209 Manifest *c2patypes.Manifest 210 210 Cert string 211 211 }
+9
pkg/model/block.go
··· 23 23 } 24 24 25 25 func (b *Block) ToStreamplaceBlock() (*streamplace.Defs_BlockView, error) { 26 + if b == nil { 27 + return nil, fmt.Errorf("block is nil") 28 + } 29 + if b.Repo == nil { 30 + return nil, fmt.Errorf("block repo is nil") 31 + } 32 + if b.Record == nil { 33 + return nil, fmt.Errorf("block record is nil") 34 + } 26 35 rec, err := lexutil.CborDecodeValue(b.Record) 27 36 if err != nil { 28 37 return nil, fmt.Errorf("error decoding feed post: %w", err)
+15
pkg/model/chat_message.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "hash/fnv" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/api/bsky" 11 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 + "github.com/rivo/uniseg" 12 14 "gorm.io/gorm" 13 15 "stream.place/streamplace/pkg/streamplace" 14 16 ) ··· 41 43 if err != nil { 42 44 return nil, fmt.Errorf("error decoding feed post: %w", err) 43 45 } 46 + // Truncate message text if it is a ChatMessage 47 + if msg, ok := rec.(*streamplace.ChatMessage); ok { 48 + graphemeCount := uniseg.GraphemeClusterCount(msg.Text) 49 + if graphemeCount > 300 { 50 + gr := uniseg.NewGraphemes(msg.Text) 51 + var result strings.Builder 52 + for count := 0; count < 300 && gr.Next(); count++ { 53 + result.WriteString(gr.Str()) 54 + } 55 + msg.Text = result.String() 56 + } 57 + } 58 + 44 59 message := &streamplace.ChatDefs_MessageView{ 45 60 LexiconTypeID: "place.stream.chat.defs#messageView", 46 61 }
+20 -24
pkg/model/livestream.go
··· 83 83 return &livestream, nil 84 84 } 85 85 86 - // GetLatestLivestreams returns the most recent livestreams, given a limit and a cursor 87 - // Only gets livestreams with a valid segment no less than 30 seconds old 88 - // Filters out livestreams or users with the !hide label 89 - func (m *DBModel) GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) { 86 + // Get the latest livestreams for a given list of repo DIDs 87 + func (m *DBModel) GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) { 90 88 var recentLivestreams []Livestream 91 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 92 89 now := time.Now().UTC() 93 90 94 - // get latest segment for the repo DID 95 - latestRecentSegmentsSubQuery := m.DB.Table("segments"). 96 - Select("repo_did, MAX(start_time) as latest_segment_start_time"). 97 - Where("(repo_did, start_time) IN (?)", 98 - m.DB.Table("segments"). 99 - Select("repo_did, MAX(start_time)"). 100 - Group("repo_did")). 101 - Where("start_time > ?", thirtySecondsAgo.UTC()). 102 - Group("repo_did") 91 + if len(dids) == 0 { 92 + return []Livestream{}, nil 93 + } 103 94 104 - rankedLivestreamsSubQuery := m.DB.Table("livestreams"). 105 - Select("livestreams.*, ROW_NUMBER() OVER(PARTITION BY livestreams.repo_did ORDER BY livestreams.created_at DESC) as rn"). 106 - Joins("JOIN repos ON livestreams.repo_did = repos.did") 95 + // Subquery to get the most recent livestream for each repo_did 96 + subQuery := m.DB. 97 + Table("livestreams"). 98 + Select("MAX(created_at) as max_created_at, repo_did"). 99 + Where("repo_did IN ?", dids). 100 + Group("repo_did") 107 101 108 - mainQuery := m.DB.Table("(?) as ranked_livestreams", rankedLivestreamsSubQuery). 109 - Joins("JOIN (?) as latest_segments ON ranked_livestreams.repo_did = latest_segments.repo_did", latestRecentSegmentsSubQuery). 110 - Select("ranked_livestreams.*, latest_segments.latest_segment_start_time"). 111 - Where("ranked_livestreams.rn = 1"). 102 + mainQuery := m.DB. 103 + Table("livestreams"). 104 + Select("livestreams.*"). 105 + Joins("JOIN (?) as sq ON livestreams.repo_did = sq.repo_did AND livestreams.created_at = sq.max_created_at", subQuery). 106 + Where("livestreams.repo_did IN ?", dids). 112 107 // exclude livestreams with !hide label on the record 113 108 Where("NOT EXISTS (?)", 114 109 m.DB.Table("labels"). 115 110 Select("1"). 116 - Where("labels.uri = ranked_livestreams.uri"). 111 + Where("labels.uri = livestreams.uri"). 117 112 Where("labels.val = ?", "!hide"). 118 113 Where("labels.neg = ?", false). 119 114 Where("(labels.exp IS NULL OR labels.exp > ?)", now), ··· 122 117 Where("NOT EXISTS (?)", 123 118 m.DB.Table("labels"). 124 119 Select("1"). 125 - Where("labels.uri = ranked_livestreams.repo_did"). 120 + Where("labels.uri = livestreams.repo_did"). 126 121 Where("labels.val = ?", "!hide"). 127 122 Where("labels.neg = ?", false). 128 123 Where("(labels.exp IS NULL OR labels.exp > ?)", now), ··· 132 127 mainQuery = mainQuery.Where("livestreams.created_at < ?", *before) 133 128 } 134 129 135 - mainQuery = mainQuery.Order("ranked_livestreams.created_at DESC"). 130 + mainQuery = mainQuery. 131 + Order("livestreams.created_at DESC"). 136 132 Limit(limit). 137 133 Preload("Repo") 138 134
+1 -16
pkg/model/model.go
··· 28 28 PlayerReport(playerID string) (map[string]any, error) 29 29 ClearPlayerEvents() error 30 30 31 - CreateSegment(segment *Segment) error 32 - MostRecentSegments() ([]Segment, error) 33 - LatestSegmentForUser(user string) (*Segment, error) 34 - LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 35 - FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 36 - CreateThumbnail(thumb *Thumbnail) error 37 - LatestThumbnailForUser(user string) (*Thumbnail, error) 38 - GetSegment(id string) (*Segment, error) 39 - GetExpiredSegments(ctx context.Context) ([]Segment, error) 40 - DeleteSegment(ctx context.Context, id string) error 41 - StartSegmentCleaner(ctx context.Context) error 42 - SegmentCleaner(ctx context.Context) error 43 - 44 31 GetIdentity(id string) (*Identity, error) 45 32 UpdateIdentity(ident *Identity) error 46 33 ··· 72 59 CreateLivestream(ctx context.Context, ls *Livestream) error 73 60 GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) 74 61 GetLivestreamByPostURI(postURI string) (*Livestream, error) 75 - GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) 62 + GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) 76 63 77 64 CreateTeleport(ctx context.Context, tp *Teleport) error 78 65 GetLatestTeleportForRepo(repoDID string) (*Teleport, error) ··· 178 165 sqlDB.SetMaxOpenConns(1) 179 166 for _, model := range []any{ 180 167 PlayerEvent{}, 181 - Segment{}, 182 - Thumbnail{}, 183 168 Identity{}, 184 169 Repo{}, 185 170 SigningKey{},
-411
pkg/model/segment.go
··· 1 1 package model 2 - 3 - import ( 4 - "context" 5 - "database/sql/driver" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "time" 10 - 11 - "gorm.io/gorm" 12 - "stream.place/streamplace/pkg/aqtime" 13 - "stream.place/streamplace/pkg/log" 14 - "stream.place/streamplace/pkg/streamplace" 15 - ) 16 - 17 - type SegmentMediadataVideo struct { 18 - Width int `json:"width"` 19 - Height int `json:"height"` 20 - FPSNum int `json:"fpsNum"` 21 - FPSDen int `json:"fpsDen"` 22 - BFrames bool `json:"bframes"` 23 - } 24 - 25 - type SegmentMediadataAudio struct { 26 - Rate int `json:"rate"` 27 - Channels int `json:"channels"` 28 - } 29 - 30 - type SegmentMediaData struct { 31 - Video []*SegmentMediadataVideo `json:"video"` 32 - Audio []*SegmentMediadataAudio `json:"audio"` 33 - Duration int64 `json:"duration"` 34 - Size int `json:"size"` 35 - } 36 - 37 - // Scan scan value into Jsonb, implements sql.Scanner interface 38 - func (j *SegmentMediaData) Scan(value any) error { 39 - bytes, ok := value.([]byte) 40 - if !ok { 41 - return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) 42 - } 43 - 44 - result := SegmentMediaData{} 45 - err := json.Unmarshal(bytes, &result) 46 - *j = SegmentMediaData(result) 47 - return err 48 - } 49 - 50 - // Value return json value, implement driver.Valuer interface 51 - func (j SegmentMediaData) Value() (driver.Value, error) { 52 - return json.Marshal(j) 53 - } 54 - 55 - // ContentRights represents content rights and attribution information 56 - type ContentRights struct { 57 - CopyrightNotice *string `json:"copyrightNotice,omitempty"` 58 - CopyrightYear *int64 `json:"copyrightYear,omitempty"` 59 - Creator *string `json:"creator,omitempty"` 60 - CreditLine *string `json:"creditLine,omitempty"` 61 - License *string `json:"license,omitempty"` 62 - } 63 - 64 - // Scan scan value into ContentRights, implements sql.Scanner interface 65 - func (c *ContentRights) Scan(value any) error { 66 - if value == nil { 67 - *c = ContentRights{} 68 - return nil 69 - } 70 - bytes, ok := value.([]byte) 71 - if !ok { 72 - return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value)) 73 - } 74 - 75 - result := ContentRights{} 76 - err := json.Unmarshal(bytes, &result) 77 - *c = ContentRights(result) 78 - return err 79 - } 80 - 81 - // Value return json value, implement driver.Valuer interface 82 - func (c ContentRights) Value() (driver.Value, error) { 83 - return json.Marshal(c) 84 - } 85 - 86 - // DistributionPolicy represents distribution policy information 87 - type DistributionPolicy struct { 88 - DeleteAfterSeconds *int64 `json:"deleteAfterSeconds,omitempty"` 89 - } 90 - 91 - // Scan scan value into DistributionPolicy, implements sql.Scanner interface 92 - func (d *DistributionPolicy) Scan(value any) error { 93 - if value == nil { 94 - *d = DistributionPolicy{} 95 - return nil 96 - } 97 - bytes, ok := value.([]byte) 98 - if !ok { 99 - return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value)) 100 - } 101 - 102 - result := DistributionPolicy{} 103 - err := json.Unmarshal(bytes, &result) 104 - *d = DistributionPolicy(result) 105 - return err 106 - } 107 - 108 - // Value return json value, implement driver.Valuer interface 109 - func (d DistributionPolicy) Value() (driver.Value, error) { 110 - return json.Marshal(d) 111 - } 112 - 113 - // ContentWarningsSlice is a custom type for storing content warnings as JSON in the database 114 - type ContentWarningsSlice []string 115 - 116 - // Scan scan value into ContentWarningsSlice, implements sql.Scanner interface 117 - func (c *ContentWarningsSlice) Scan(value any) error { 118 - if value == nil { 119 - *c = ContentWarningsSlice{} 120 - return nil 121 - } 122 - bytes, ok := value.([]byte) 123 - if !ok { 124 - return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value)) 125 - } 126 - 127 - result := ContentWarningsSlice{} 128 - err := json.Unmarshal(bytes, &result) 129 - *c = ContentWarningsSlice(result) 130 - return err 131 - } 132 - 133 - // Value return json value, implement driver.Valuer interface 134 - func (c ContentWarningsSlice) Value() (driver.Value, error) { 135 - return json.Marshal(c) 136 - } 137 - 138 - type Segment struct { 139 - ID string `json:"id" gorm:"primaryKey"` 140 - SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"` 141 - SigningKey *SigningKey `json:"signingKey,omitempty" gorm:"foreignKey:DID;references:SigningKeyDID"` 142 - StartTime time.Time `json:"startTime" gorm:"index:latest_segments,priority:2;index:start_time"` 143 - RepoDID string `json:"repoDID" gorm:"index:latest_segments,priority:1;column:repo_did"` 144 - Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 145 - Title string `json:"title"` 146 - Size int `json:"size" gorm:"column:size"` 147 - MediaData *SegmentMediaData `json:"mediaData,omitempty"` 148 - ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"` 149 - ContentRights *ContentRights `json:"contentRights,omitempty"` 150 - DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"` 151 - DeleteAfter *time.Time `json:"deleteAfter,omitempty" gorm:"column:delete_after;index:delete_after"` 152 - } 153 - 154 - func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) { 155 - aqt := aqtime.FromTime(s.StartTime) 156 - if s.MediaData == nil { 157 - return nil, fmt.Errorf("media data is nil") 158 - } 159 - if len(s.MediaData.Video) == 0 || s.MediaData.Video[0] == nil { 160 - return nil, fmt.Errorf("video data is nil") 161 - } 162 - if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 163 - return nil, fmt.Errorf("audio data is nil") 164 - } 165 - duration := s.MediaData.Duration 166 - sizei64 := int64(s.Size) 167 - 168 - // Convert model metadata to streamplace metadata 169 - var contentRights *streamplace.MetadataContentRights 170 - if s.ContentRights != nil { 171 - contentRights = &streamplace.MetadataContentRights{ 172 - CopyrightNotice: s.ContentRights.CopyrightNotice, 173 - CopyrightYear: s.ContentRights.CopyrightYear, 174 - Creator: s.ContentRights.Creator, 175 - CreditLine: s.ContentRights.CreditLine, 176 - License: s.ContentRights.License, 177 - } 178 - } 179 - 180 - var contentWarnings *streamplace.MetadataContentWarnings 181 - if len(s.ContentWarnings) > 0 { 182 - contentWarnings = &streamplace.MetadataContentWarnings{ 183 - Warnings: []string(s.ContentWarnings), 184 - } 185 - } 186 - 187 - var distributionPolicy *streamplace.MetadataDistributionPolicy 188 - if s.DistributionPolicy != nil && s.DistributionPolicy.DeleteAfterSeconds != nil { 189 - distributionPolicy = &streamplace.MetadataDistributionPolicy{ 190 - DeleteAfter: s.DistributionPolicy.DeleteAfterSeconds, 191 - } 192 - } 193 - 194 - return &streamplace.Segment{ 195 - LexiconTypeID: "place.stream.segment", 196 - Creator: s.RepoDID, 197 - Id: s.ID, 198 - SigningKey: s.SigningKeyDID, 199 - StartTime: string(aqt), 200 - Duration: &duration, 201 - Size: &sizei64, 202 - ContentRights: contentRights, 203 - ContentWarnings: contentWarnings, 204 - DistributionPolicy: distributionPolicy, 205 - Video: []*streamplace.Segment_Video{ 206 - { 207 - Codec: "h264", 208 - Width: int64(s.MediaData.Video[0].Width), 209 - Height: int64(s.MediaData.Video[0].Height), 210 - Framerate: &streamplace.Segment_Framerate{ 211 - Num: int64(s.MediaData.Video[0].FPSNum), 212 - Den: int64(s.MediaData.Video[0].FPSDen), 213 - }, 214 - Bframes: &s.MediaData.Video[0].BFrames, 215 - }, 216 - }, 217 - Audio: []*streamplace.Segment_Audio{ 218 - { 219 - Codec: "opus", 220 - Rate: int64(s.MediaData.Audio[0].Rate), 221 - Channels: int64(s.MediaData.Audio[0].Channels), 222 - }, 223 - }, 224 - }, nil 225 - } 226 - 227 - func (m *DBModel) CreateSegment(seg *Segment) error { 228 - err := m.DB.Model(Segment{}).Create(seg).Error 229 - if err != nil { 230 - return err 231 - } 232 - return nil 233 - } 234 - 235 - // should return the most recent segment for each user, ordered by most recent first 236 - // only includes segments from the last 30 seconds 237 - func (m *DBModel) MostRecentSegments() ([]Segment, error) { 238 - var segments []Segment 239 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 240 - 241 - err := m.DB.Table("segments"). 242 - Select("segments.*"). 243 - Where("start_time > ?", thirtySecondsAgo.UTC()). 244 - Order("start_time DESC"). 245 - Find(&segments).Error 246 - if err != nil { 247 - return nil, err 248 - } 249 - if segments == nil { 250 - return []Segment{}, nil 251 - } 252 - 253 - segmentMap := make(map[string]Segment) 254 - for _, seg := range segments { 255 - prev, ok := segmentMap[seg.RepoDID] 256 - if !ok { 257 - segmentMap[seg.RepoDID] = seg 258 - } else { 259 - if seg.StartTime.After(prev.StartTime) { 260 - segmentMap[seg.RepoDID] = seg 261 - } 262 - } 263 - } 264 - 265 - filteredSegments := []Segment{} 266 - for _, seg := range segmentMap { 267 - filteredSegments = append(filteredSegments, seg) 268 - } 269 - 270 - return filteredSegments, nil 271 - } 272 - 273 - func (m *DBModel) LatestSegmentForUser(user string) (*Segment, error) { 274 - var seg Segment 275 - err := m.DB.Model(Segment{}).Where("repo_did = ?", user).Order("start_time DESC").First(&seg).Error 276 - if err != nil { 277 - return nil, err 278 - } 279 - return &seg, nil 280 - } 281 - 282 - func (m *DBModel) FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) { 283 - if len(repoDIDs) == 0 { 284 - return []string{}, nil 285 - } 286 - 287 - thirtySecondsAgo := time.Now().Add(-30 * time.Second) 288 - 289 - var liveDIDs []string 290 - 291 - err := m.DB.Table("segments"). 292 - Select("DISTINCT repo_did"). 293 - Where("repo_did IN ? AND start_time > ?", repoDIDs, thirtySecondsAgo.UTC()). 294 - Pluck("repo_did", &liveDIDs).Error 295 - 296 - if err != nil { 297 - return nil, err 298 - } 299 - 300 - return liveDIDs, nil 301 - } 302 - 303 - func (m *DBModel) LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) { 304 - var segs []Segment 305 - if before == nil { 306 - later := time.Now().Add(1000 * time.Hour) 307 - before = &later 308 - } 309 - if after == nil { 310 - earlier := time.Time{} 311 - after = &earlier 312 - } 313 - err := m.DB.Model(Segment{}).Where("repo_did = ? AND start_time < ? AND start_time > ?", user, before.UTC(), after.UTC()).Order("start_time DESC").Limit(limit).Find(&segs).Error 314 - if err != nil { 315 - return nil, err 316 - } 317 - return segs, nil 318 - } 319 - 320 - func (m *DBModel) GetSegment(id string) (*Segment, error) { 321 - var seg Segment 322 - 323 - err := m.DB.Model(&Segment{}). 324 - Preload("Repo"). 325 - Where("id = ?", id). 326 - First(&seg).Error 327 - 328 - if errors.Is(err, gorm.ErrRecordNotFound) { 329 - return nil, nil 330 - } 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return &seg, nil 336 - } 337 - 338 - func (m *DBModel) GetExpiredSegments(ctx context.Context) ([]Segment, error) { 339 - 340 - var expiredSegments []Segment 341 - now := time.Now() 342 - err := m.DB. 343 - Where("delete_after IS NOT NULL AND delete_after < ?", now.UTC()). 344 - Find(&expiredSegments).Error 345 - if err != nil { 346 - return nil, err 347 - } 348 - 349 - return expiredSegments, nil 350 - } 351 - 352 - func (m *DBModel) DeleteSegment(ctx context.Context, id string) error { 353 - return m.DB.Delete(&Segment{}, "id = ?", id).Error 354 - } 355 - 356 - func (m *DBModel) StartSegmentCleaner(ctx context.Context) error { 357 - err := m.SegmentCleaner(ctx) 358 - if err != nil { 359 - return err 360 - } 361 - ticker := time.NewTicker(1 * time.Minute) 362 - defer ticker.Stop() 363 - 364 - for { 365 - select { 366 - case <-ctx.Done(): 367 - return nil 368 - case <-ticker.C: 369 - err := m.SegmentCleaner(ctx) 370 - if err != nil { 371 - log.Error(ctx, "Failed to clean segments", "error", err) 372 - } 373 - } 374 - } 375 - } 376 - 377 - func (m *DBModel) SegmentCleaner(ctx context.Context) error { 378 - // Calculate the cutoff time (10 minutes ago) 379 - cutoffTime := aqtime.FromTime(time.Now().Add(-10 * time.Minute)).Time() 380 - 381 - // Find all unique repo_did values 382 - var repoDIDs []string 383 - if err := m.DB.Model(&Segment{}).Distinct("repo_did").Pluck("repo_did", &repoDIDs).Error; err != nil { 384 - log.Error(ctx, "Failed to get unique repo_dids for segment cleaning", "error", err) 385 - return err 386 - } 387 - 388 - // For each user, keep their last 10 segments and delete older ones 389 - for _, repoDID := range repoDIDs { 390 - // Get IDs of the last 10 segments for this user 391 - var keepSegmentIDs []string 392 - if err := m.DB.Model(&Segment{}). 393 - Where("repo_did = ?", repoDID). 394 - Order("start_time DESC"). 395 - Limit(10). 396 - Pluck("id", &keepSegmentIDs).Error; err != nil { 397 - log.Error(ctx, "Failed to get segment IDs to keep", "repo_did", repoDID, "error", err) 398 - return err 399 - } 400 - 401 - // Delete old segments except the ones we want to keep 402 - result := m.DB.Where("repo_did = ? AND start_time < ? AND id NOT IN ?", 403 - repoDID, cutoffTime, keepSegmentIDs).Delete(&Segment{}) 404 - 405 - if result.Error != nil { 406 - log.Error(ctx, "Failed to clean old segments", "repo_did", repoDID, "error", result.Error) 407 - } else if result.RowsAffected > 0 { 408 - log.Log(ctx, "Cleaned old segments", "repo_did", repoDID, "count", result.RowsAffected) 409 - } 410 - } 411 - return nil 412 - }
-65
pkg/model/segment_test.go
··· 1 1 package model 2 - 3 - import ( 4 - "fmt" 5 - "sync" 6 - "testing" 7 - "time" 8 - 9 - "github.com/stretchr/testify/require" 10 - "stream.place/streamplace/pkg/config" 11 - ) 12 - 13 - func TestSegmentPerf(t *testing.T) { 14 - config.DisableSQLLogging() 15 - // dburl := filepath.Join(t.TempDir(), "test.db") 16 - db, err := MakeDB(":memory:") 17 - require.NoError(t, err) 18 - // Create a model instance 19 - model := db.(*DBModel) 20 - t.Cleanup(func() { 21 - // os.Remove(dburl) 22 - }) 23 - 24 - // Create a repo for testing 25 - repo := &Repo{ 26 - DID: "did:plc:test123", 27 - } 28 - err = model.DB.Create(repo).Error 29 - require.NoError(t, err) 30 - 31 - defer config.EnableSQLLogging() 32 - // Create 250000 segments with timestamps 1 hour ago, each one second apart 33 - wg := sync.WaitGroup{} 34 - segCount := 250000 35 - wg.Add(segCount) 36 - baseTime := time.Now() 37 - for i := 0; i < segCount; i++ { 38 - segment := &Segment{ 39 - ID: fmt.Sprintf("segment-%d", i), 40 - RepoDID: repo.DID, 41 - StartTime: baseTime.Add(-time.Duration(i) * time.Second).UTC(), 42 - } 43 - go func() { 44 - defer wg.Done() 45 - err = model.DB.Create(segment).Error 46 - require.NoError(t, err) 47 - }() 48 - } 49 - wg.Wait() 50 - 51 - startTime := time.Now() 52 - wg = sync.WaitGroup{} 53 - runs := 1000 54 - wg.Add(runs) 55 - for i := 0; i < runs; i++ { 56 - go func() { 57 - defer wg.Done() 58 - _, err := model.MostRecentSegments() 59 - require.NoError(t, err) 60 - // require.Len(t, segments, 1) 61 - }() 62 - } 63 - wg.Wait() 64 - fmt.Printf("Time taken: %s\n", time.Since(startTime)) 65 - require.Less(t, time.Since(startTime), 10*time.Second) 66 - }
-59
pkg/model/thumbnail.go
··· 1 1 package model 2 - 3 - import ( 4 - "fmt" 5 - 6 - "github.com/google/uuid" 7 - ) 8 - 9 - type Thumbnail struct { 10 - ID string `json:"id" gorm:"primaryKey"` 11 - Format string `json:"format"` 12 - SegmentID string `json:"segmentId" gorm:"index"` 13 - Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 - } 15 - 16 - func (m *DBModel) CreateThumbnail(thumb *Thumbnail) error { 17 - uu, err := uuid.NewV7() 18 - if err != nil { 19 - return err 20 - } 21 - if thumb.SegmentID == "" { 22 - return fmt.Errorf("segmentID is required") 23 - } 24 - thumb.ID = uu.String() 25 - err = m.DB.Model(Thumbnail{}).Create(thumb).Error 26 - if err != nil { 27 - return err 28 - } 29 - return nil 30 - } 31 - 32 - // return the most recent thumbnail for a user 33 - func (m *DBModel) LatestThumbnailForUser(user string) (*Thumbnail, error) { 34 - var thumbnail Thumbnail 35 - 36 - res := m.DB.Table("thumbnails AS t"). 37 - Select("t.*"). 38 - Joins("JOIN segments AS s ON t.segment_id = s.id"). 39 - Where("s.repo_did = ?", user). 40 - Order("s.start_time DESC"). 41 - Limit(1). 42 - Scan(&thumbnail) 43 - 44 - if res.RowsAffected == 0 { 45 - return nil, nil 46 - } 47 - if res.Error != nil { 48 - return nil, res.Error 49 - } 50 - 51 - var seg Segment 52 - err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 53 - if err != nil { 54 - return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 55 - } 56 - 57 - thumbnail.Segment = seg 58 - 59 - return &thumbnail, nil 60 - }
+1 -1
pkg/spxrpc/app_bsky_feed.go
··· 56 56 outCursor = fmt.Sprintf("%d::%s", ts, last.CID) 57 57 } 58 58 } else if name == FeedLiveStreams { 59 - segs, err := s.model.MostRecentSegments() 59 + segs, err := s.localDB.MostRecentSegments() 60 60 if err != nil { 61 61 return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get recent segments: %v", err)) 62 62 }
+2 -1
pkg/spxrpc/com_atproto_identity.go
··· 5 5 6 6 comatprototypes "github.com/bluesky-social/indigo/api/atproto" 7 7 "github.com/streamplace/oatproxy/pkg/oatproxy" 8 + "stream.place/streamplace/pkg/aqhttp" 8 9 ) 9 10 10 11 func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { 11 - did, err := oatproxy.ResolveHandle(ctx, handle) 12 + did, err := oatproxy.ResolveHandleWithClient(ctx, handle, &aqhttp.Client) 12 13 if err != nil { 13 14 return nil, err 14 15 }
+4 -4
pkg/spxrpc/com_atproto_moderation.go
··· 13 13 "github.com/labstack/echo/v4" 14 14 "github.com/streamplace/oatproxy/pkg/oatproxy" 15 15 "stream.place/streamplace/pkg/config" 16 + "stream.place/streamplace/pkg/localdb" 16 17 "stream.place/streamplace/pkg/log" 17 18 "stream.place/streamplace/pkg/media" 18 - "stream.place/streamplace/pkg/model" 19 19 ) 20 20 21 21 func (s *Server) handleComAtprotoModerationCreateReport(ctx context.Context, body *comatprototypes.ModerationCreateReport_Input) (*comatprototypes.ModerationCreateReport_Output, error) { ··· 76 76 return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid subject") 77 77 } 78 78 79 - clipID, err := makeClip(ctx, s.cli, s.model, did) 79 + clipID, err := makeClip(ctx, s.cli, s.localDB, did) 80 80 if err != nil { 81 81 // we still want the report to go through! 82 82 log.Error(ctx, "failed to make clip for report", "error", err) ··· 99 99 return &output, nil 100 100 } 101 101 102 - func makeClip(ctx context.Context, cli *config.CLI, mod model.Model, did string) (string, error) { 102 + func makeClip(ctx context.Context, cli *config.CLI, localDB localdb.LocalDB, did string) (string, error) { 103 103 after := time.Now().Add(-time.Duration(60) * time.Second) 104 104 105 105 uu, err := uuid.NewV7() ··· 113 113 } 114 114 defer fd.Close() 115 115 116 - err = media.ClipUser(ctx, mod, cli, did, fd, nil, &after) 116 + err = media.ClipUser(ctx, localDB, cli, did, fd, nil, &after) 117 117 if err != nil { 118 118 return "", echo.NewHTTPError(http.StatusInternalServerError, "failed to clip user") 119 119 }
+3 -2
pkg/spxrpc/com_atproto_repo.go
··· 15 15 "github.com/labstack/echo/v4" 16 16 "github.com/streamplace/oatproxy/pkg/oatproxy" 17 17 "go.opentelemetry.io/otel" 18 + "stream.place/streamplace/pkg/aqhttp" 18 19 "stream.place/streamplace/pkg/atproto" 19 20 "stream.place/streamplace/pkg/log" 20 21 ) ··· 23 24 did := repo 24 25 var err error 25 26 if !strings.HasPrefix(repo, "did:") { 26 - did, err = oatproxy.ResolveHandle(ctx, repo) 27 + did, err = oatproxy.ResolveHandleWithClient(ctx, repo, &aqhttp.Client) 27 28 if err != nil { 28 29 return "", "", "", fmt.Errorf("failed to resolve handle %q: %w", repo, err) 29 30 } 30 31 } 31 32 32 - service, handle, err := oatproxy.ResolveService(ctx, did) 33 + service, handle, err := oatproxy.ResolveServiceWithClient(ctx, did, &aqhttp.Client) 33 34 if err != nil { 34 35 return "", "", "", fmt.Errorf("failed to resolve service for did %q: %w", did, err) 35 36 }
+11
pkg/spxrpc/com_atproto_sync.go
··· 46 46 }, 47 47 } 48 48 49 + func (s *Server) handleComAtprotoSyncGetRepo(ctx context.Context, did string, since string) (io.Reader, error) { 50 + if did != atproto.LexiconRepo.RepoDid() { 51 + return nil, echo.NewHTTPError(http.StatusNotFound, "RepoNotFound") 52 + } 53 + bs, err := atproto.LexiconRepoGetRepo(ctx, since) 54 + if err != nil { 55 + return nil, err 56 + } 57 + return bytes.NewReader(bs), nil 58 + } 59 + 49 60 func (s *Server) handleComAtprotoSyncSubscribeRepos(c echo.Context) error { 50 61 ctx := log.WithLogValues(c.Request().Context(), "client_ip", c.RealIP(), "user_agent", c.Request().UserAgent()) 51 62 cursor := c.QueryParam("cursor")
+4 -4
pkg/spxrpc/place_stream_branding.go
··· 38 38 return s.cli.BroadcasterHost 39 39 } 40 40 41 - func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 41 + func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 42 // cache miss - fetch from db 43 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 44 if err == gorm.ErrRecordNotFound { ··· 61 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 64 + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) 65 65 if err != nil { 66 66 return nil, err 67 67 } ··· 94 94 // build output 95 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 96 for key := range allKeys { 97 - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 97 + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) 98 98 if err != nil { 99 99 continue // skip if error 100 100 } ··· 238 238 239 239 broadcasterID := s.cli.BroadcasterHost 240 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 241 + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 242 243 243 if err != nil || data == nil { 244 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
+13 -5
pkg/spxrpc/place_stream_live.go
··· 82 82 beforeTime = &parsedTime 83 83 } 84 84 85 - segments, err := s.model.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 85 + segments, err := s.localDB.LatestSegmentsForUser(userDID, limit, beforeTime, nil) 86 86 if err != nil { 87 87 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch segments") 88 88 } ··· 127 127 } 128 128 beforeTime = &parsedTime 129 129 } 130 - ls, err := s.model.GetLatestLivestreams(limit, beforeTime) 130 + segs, err := s.localDB.MostRecentSegments() 131 + if err != nil { 132 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch recent segments") 133 + } 134 + dids := make([]string, len(segs)) 135 + for i, seg := range segs { 136 + dids[i] = seg.RepoDID 137 + } 138 + ls, err := s.model.GetLatestLivestreams(limit, beforeTime, dids) 131 139 if err != nil { 132 140 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch livestreams") 133 141 } ··· 223 231 } 224 232 225 233 // Filter for only live streamers 226 - liveStreamers, err := s.model.FilterLiveRepoDIDs(streamers) 234 + liveStreamers, err := s.localDB.FilterLiveRepoDIDs(streamers) 227 235 if err != nil { 228 236 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live streamers") 229 237 } ··· 256 264 followDIDs[i] = follow.SubjectDID 257 265 } 258 266 259 - liveFollows, err := s.model.FilterLiveRepoDIDs(followDIDs) 267 + liveFollows, err := s.localDB.FilterLiveRepoDIDs(followDIDs) 260 268 if err != nil { 261 269 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter live follows") 262 270 } ··· 281 289 // Final fallback: use host's default recommendations 282 290 defaultStreamers := s.cli.DefaultRecommendedStreamers 283 291 if len(defaultStreamers) > 0 { 284 - liveDefaults, err := s.model.FilterLiveRepoDIDs(defaultStreamers) 292 + liveDefaults, err := s.localDB.FilterLiveRepoDIDs(defaultStreamers) 285 293 if err != nil { 286 294 return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to filter default streamers") 287 295 }
+4 -1
pkg/spxrpc/spxrpc.go
··· 18 18 "stream.place/streamplace/pkg/atproto" 19 19 "stream.place/streamplace/pkg/bus" 20 20 "stream.place/streamplace/pkg/config" 21 + "stream.place/streamplace/pkg/localdb" 21 22 "stream.place/streamplace/pkg/log" 22 23 "stream.place/streamplace/pkg/model" 23 24 "stream.place/streamplace/pkg/statedb" ··· 33 34 statefulDB *statedb.StatefulDB 34 35 bus *bus.Bus 35 36 op *oatproxy.OATProxy 37 + localDB localdb.LocalDB 36 38 } 37 39 38 - func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus) (*Server, error) { 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) { 39 41 e := echo.New() 40 42 s := &Server{ 41 43 e: e, ··· 47 49 statefulDB: statefulDB, 48 50 bus: bus, 49 51 op: op, 52 + localDB: ldb, 50 53 } 51 54 e.Use(s.ErrorHandlingMiddleware()) 52 55 e.Use(s.ContextPreservingMiddleware())
+16
pkg/spxrpc/stubs.go
··· 71 71 e.POST("/xrpc/com.atproto.repo.uploadBlob", s.HandleComAtprotoRepoUploadBlob) 72 72 e.GET("/xrpc/com.atproto.server.describeServer", s.HandleComAtprotoServerDescribeServer) 73 73 e.GET("/xrpc/com.atproto.sync.getRecord", s.HandleComAtprotoSyncGetRecord) 74 + e.GET("/xrpc/com.atproto.sync.getRepo", s.HandleComAtprotoSyncGetRepo) 74 75 e.GET("/xrpc/com.atproto.sync.listRepos", s.HandleComAtprotoSyncListRepos) 75 76 return nil 76 77 } ··· 230 231 var handleErr error 231 232 // func (s *Server) handleComAtprotoSyncGetRecord(ctx context.Context,collection string,did string,rkey string) (io.Reader, error) 232 233 out, handleErr = s.handleComAtprotoSyncGetRecord(ctx, collection, did, rkey) 234 + if handleErr != nil { 235 + return handleErr 236 + } 237 + return c.Stream(200, "application/vnd.ipld.car", out) 238 + } 239 + 240 + func (s *Server) HandleComAtprotoSyncGetRepo(c echo.Context) error { 241 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoSyncGetRepo") 242 + defer span.End() 243 + did := c.QueryParam("did") 244 + since := c.QueryParam("since") 245 + var out io.Reader 246 + var handleErr error 247 + // func (s *Server) handleComAtprotoSyncGetRepo(ctx context.Context,did string,since string) (io.Reader, error) 248 + out, handleErr = s.handleComAtprotoSyncGetRepo(ctx, did, since) 233 249 if handleErr != nil { 234 250 return handleErr 235 251 }
+6 -6
pkg/storage/storage.go
··· 10 10 "golang.org/x/sync/errgroup" 11 11 "stream.place/streamplace/pkg/aqtime" 12 12 "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/localdb" 13 14 "stream.place/streamplace/pkg/log" 14 - "stream.place/streamplace/pkg/model" 15 15 ) 16 16 17 17 const moderationRetention = 120 * time.Second 18 18 19 - func StartSegmentCleaner(ctx context.Context, mod model.Model, cli *config.CLI) error { 19 + func StartSegmentCleaner(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI) error { 20 20 ctx = log.WithLogValues(ctx, "func", "StartSegmentCleaner") 21 21 g, ctx := errgroup.WithContext(ctx) 22 22 g.Go(func() error { ··· 25 25 case <-ctx.Done(): 26 26 return nil 27 27 case <-time.After(60 * time.Second): 28 - expiredSegments, err := mod.GetExpiredSegments(ctx) 28 + expiredSegments, err := localDB.GetExpiredSegments(ctx) 29 29 if err != nil { 30 30 return err 31 31 } 32 32 log.Log(ctx, "Cleaning expired segments", "count", len(expiredSegments)) 33 33 for _, seg := range expiredSegments { 34 34 g.Go(func() error { 35 - err := deleteSegment(ctx, mod, cli, seg) 35 + err := deleteSegment(ctx, localDB, cli, seg) 36 36 if err != nil { 37 37 log.Error(ctx, "Failed to delete segment", "error", err) 38 38 } ··· 47 47 return g.Wait() 48 48 } 49 49 50 - func deleteSegment(ctx context.Context, mod model.Model, cli *config.CLI, seg model.Segment) error { 50 + func deleteSegment(ctx context.Context, localDB localdb.LocalDB, cli *config.CLI, seg localdb.Segment) error { 51 51 if time.Since(seg.StartTime) < moderationRetention { 52 52 log.Debug(ctx, "Skipping deletion of segment", "id", seg.ID, "time since start", time.Since(seg.StartTime)) 53 53 return nil ··· 61 61 if err != nil && !errors.Is(err, os.ErrNotExist) { 62 62 return err 63 63 } 64 - err = mod.DeleteSegment(ctx, seg.ID) 64 + err = localDB.DeleteSegment(ctx, seg.ID) 65 65 if err != nil { 66 66 return err 67 67 }
+18
pkg/streamplace/badgedefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.defs 4 + 5 + package streamplace 6 + 7 + // BadgeDefs_BadgeView is a "badgeView" in the place.stream.badge.defs schema. 8 + // 9 + // View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. 10 + type BadgeDefs_BadgeView struct { 11 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 12 + // issuer: DID of the badge issuer. 13 + Issuer string `json:"issuer" cborgen:"issuer"` 14 + // recipient: DID of the badge recipient. 15 + Recipient string `json:"recipient" cborgen:"recipient"` 16 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 17 + Signature *string `json:"signature,omitempty" cborgen:"signature,omitempty"` 18 + }
+28
pkg/streamplace/badgedisplay.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.display 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.badge.display", &BadgeDisplay{}) 13 + } 14 + 15 + type BadgeDisplay struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.display"` 17 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. 18 + Badges []*BadgeDisplay_BadgeSelection `json:"badges" cborgen:"badges"` 19 + } 20 + 21 + // BadgeDisplay_BadgeSelection is a "badgeSelection" in the place.stream.badge.display schema. 22 + // 23 + // A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. 24 + type BadgeDisplay_BadgeSelection struct { 25 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 26 + // issuance: URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. 27 + Issuance *string `json:"issuance,omitempty" cborgen:"issuance,omitempty"` 28 + }
+22
pkg/streamplace/badgeissuance.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.issuance 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.badge.issuance", &BadgeIssuance{}) 13 + } 14 + 15 + type BadgeIssuance struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.issuance"` 17 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 18 + // recipient: DID of the badge recipient. 19 + Recipient string `json:"recipient" cborgen:"recipient"` 20 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 21 + Signature string `json:"signature" cborgen:"signature"` 22 + }
+527
pkg/streamplace/cbor_gen.go
··· 5966 5966 5967 5967 return nil 5968 5968 } 5969 + func (t *BadgeIssuance) MarshalCBOR(w io.Writer) error { 5970 + if t == nil { 5971 + _, err := w.Write(cbg.CborNull) 5972 + return err 5973 + } 5974 + 5975 + cw := cbg.NewCborWriter(w) 5976 + 5977 + if _, err := cw.Write([]byte{164}); err != nil { 5978 + return err 5979 + } 5980 + 5981 + // t.LexiconTypeID (string) (string) 5982 + if len("$type") > 1000000 { 5983 + return xerrors.Errorf("Value in field \"$type\" was too long") 5984 + } 5985 + 5986 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5987 + return err 5988 + } 5989 + if _, err := cw.WriteString(string("$type")); err != nil { 5990 + return err 5991 + } 5992 + 5993 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.issuance"))); err != nil { 5994 + return err 5995 + } 5996 + if _, err := cw.WriteString(string("place.stream.badge.issuance")); err != nil { 5997 + return err 5998 + } 5999 + 6000 + // t.BadgeType (string) (string) 6001 + if len("badgeType") > 1000000 { 6002 + return xerrors.Errorf("Value in field \"badgeType\" was too long") 6003 + } 6004 + 6005 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { 6006 + return err 6007 + } 6008 + if _, err := cw.WriteString(string("badgeType")); err != nil { 6009 + return err 6010 + } 6011 + 6012 + if len(t.BadgeType) > 1000000 { 6013 + return xerrors.Errorf("Value in field t.BadgeType was too long") 6014 + } 6015 + 6016 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { 6017 + return err 6018 + } 6019 + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { 6020 + return err 6021 + } 6022 + 6023 + // t.Recipient (string) (string) 6024 + if len("recipient") > 1000000 { 6025 + return xerrors.Errorf("Value in field \"recipient\" was too long") 6026 + } 6027 + 6028 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("recipient"))); err != nil { 6029 + return err 6030 + } 6031 + if _, err := cw.WriteString(string("recipient")); err != nil { 6032 + return err 6033 + } 6034 + 6035 + if len(t.Recipient) > 1000000 { 6036 + return xerrors.Errorf("Value in field t.Recipient was too long") 6037 + } 6038 + 6039 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Recipient))); err != nil { 6040 + return err 6041 + } 6042 + if _, err := cw.WriteString(string(t.Recipient)); err != nil { 6043 + return err 6044 + } 6045 + 6046 + // t.Signature (string) (string) 6047 + if len("signature") > 1000000 { 6048 + return xerrors.Errorf("Value in field \"signature\" was too long") 6049 + } 6050 + 6051 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("signature"))); err != nil { 6052 + return err 6053 + } 6054 + if _, err := cw.WriteString(string("signature")); err != nil { 6055 + return err 6056 + } 6057 + 6058 + if len(t.Signature) > 1000000 { 6059 + return xerrors.Errorf("Value in field t.Signature was too long") 6060 + } 6061 + 6062 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Signature))); err != nil { 6063 + return err 6064 + } 6065 + if _, err := cw.WriteString(string(t.Signature)); err != nil { 6066 + return err 6067 + } 6068 + return nil 6069 + } 6070 + 6071 + func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { 6072 + *t = BadgeIssuance{} 6073 + 6074 + cr := cbg.NewCborReader(r) 6075 + 6076 + maj, extra, err := cr.ReadHeader() 6077 + if err != nil { 6078 + return err 6079 + } 6080 + defer func() { 6081 + if err == io.EOF { 6082 + err = io.ErrUnexpectedEOF 6083 + } 6084 + }() 6085 + 6086 + if maj != cbg.MajMap { 6087 + return fmt.Errorf("cbor input should be of type map") 6088 + } 6089 + 6090 + if extra > cbg.MaxLength { 6091 + return fmt.Errorf("BadgeIssuance: map struct too large (%d)", extra) 6092 + } 6093 + 6094 + n := extra 6095 + 6096 + nameBuf := make([]byte, 9) 6097 + for i := uint64(0); i < n; i++ { 6098 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6099 + if err != nil { 6100 + return err 6101 + } 6102 + 6103 + if !ok { 6104 + // Field doesn't exist on this type, so ignore it 6105 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6106 + return err 6107 + } 6108 + continue 6109 + } 6110 + 6111 + switch string(nameBuf[:nameLen]) { 6112 + // t.LexiconTypeID (string) (string) 6113 + case "$type": 6114 + 6115 + { 6116 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6117 + if err != nil { 6118 + return err 6119 + } 6120 + 6121 + t.LexiconTypeID = string(sval) 6122 + } 6123 + // t.BadgeType (string) (string) 6124 + case "badgeType": 6125 + 6126 + { 6127 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6128 + if err != nil { 6129 + return err 6130 + } 6131 + 6132 + t.BadgeType = string(sval) 6133 + } 6134 + // t.Recipient (string) (string) 6135 + case "recipient": 6136 + 6137 + { 6138 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6139 + if err != nil { 6140 + return err 6141 + } 6142 + 6143 + t.Recipient = string(sval) 6144 + } 6145 + // t.Signature (string) (string) 6146 + case "signature": 6147 + 6148 + { 6149 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6150 + if err != nil { 6151 + return err 6152 + } 6153 + 6154 + t.Signature = string(sval) 6155 + } 6156 + 6157 + default: 6158 + // Field doesn't exist on this type, so ignore it 6159 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6160 + return err 6161 + } 6162 + } 6163 + } 6164 + 6165 + return nil 6166 + } 6167 + func (t *BadgeDisplay) MarshalCBOR(w io.Writer) error { 6168 + if t == nil { 6169 + _, err := w.Write(cbg.CborNull) 6170 + return err 6171 + } 6172 + 6173 + cw := cbg.NewCborWriter(w) 6174 + 6175 + if _, err := cw.Write([]byte{162}); err != nil { 6176 + return err 6177 + } 6178 + 6179 + // t.LexiconTypeID (string) (string) 6180 + if len("$type") > 1000000 { 6181 + return xerrors.Errorf("Value in field \"$type\" was too long") 6182 + } 6183 + 6184 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 6185 + return err 6186 + } 6187 + if _, err := cw.WriteString(string("$type")); err != nil { 6188 + return err 6189 + } 6190 + 6191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.display"))); err != nil { 6192 + return err 6193 + } 6194 + if _, err := cw.WriteString(string("place.stream.badge.display")); err != nil { 6195 + return err 6196 + } 6197 + 6198 + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) 6199 + if len("badges") > 1000000 { 6200 + return xerrors.Errorf("Value in field \"badges\" was too long") 6201 + } 6202 + 6203 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badges"))); err != nil { 6204 + return err 6205 + } 6206 + if _, err := cw.WriteString(string("badges")); err != nil { 6207 + return err 6208 + } 6209 + 6210 + if len(t.Badges) > 8192 { 6211 + return xerrors.Errorf("Slice value in field t.Badges was too long") 6212 + } 6213 + 6214 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Badges))); err != nil { 6215 + return err 6216 + } 6217 + for _, v := range t.Badges { 6218 + if err := v.MarshalCBOR(cw); err != nil { 6219 + return err 6220 + } 6221 + 6222 + } 6223 + return nil 6224 + } 6225 + 6226 + func (t *BadgeDisplay) UnmarshalCBOR(r io.Reader) (err error) { 6227 + *t = BadgeDisplay{} 6228 + 6229 + cr := cbg.NewCborReader(r) 6230 + 6231 + maj, extra, err := cr.ReadHeader() 6232 + if err != nil { 6233 + return err 6234 + } 6235 + defer func() { 6236 + if err == io.EOF { 6237 + err = io.ErrUnexpectedEOF 6238 + } 6239 + }() 6240 + 6241 + if maj != cbg.MajMap { 6242 + return fmt.Errorf("cbor input should be of type map") 6243 + } 6244 + 6245 + if extra > cbg.MaxLength { 6246 + return fmt.Errorf("BadgeDisplay: map struct too large (%d)", extra) 6247 + } 6248 + 6249 + n := extra 6250 + 6251 + nameBuf := make([]byte, 6) 6252 + for i := uint64(0); i < n; i++ { 6253 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6254 + if err != nil { 6255 + return err 6256 + } 6257 + 6258 + if !ok { 6259 + // Field doesn't exist on this type, so ignore it 6260 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6261 + return err 6262 + } 6263 + continue 6264 + } 6265 + 6266 + switch string(nameBuf[:nameLen]) { 6267 + // t.LexiconTypeID (string) (string) 6268 + case "$type": 6269 + 6270 + { 6271 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6272 + if err != nil { 6273 + return err 6274 + } 6275 + 6276 + t.LexiconTypeID = string(sval) 6277 + } 6278 + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) 6279 + case "badges": 6280 + 6281 + maj, extra, err = cr.ReadHeader() 6282 + if err != nil { 6283 + return err 6284 + } 6285 + 6286 + if extra > 8192 { 6287 + return fmt.Errorf("t.Badges: array too large (%d)", extra) 6288 + } 6289 + 6290 + if maj != cbg.MajArray { 6291 + return fmt.Errorf("expected cbor array") 6292 + } 6293 + 6294 + if extra > 0 { 6295 + t.Badges = make([]*BadgeDisplay_BadgeSelection, extra) 6296 + } 6297 + 6298 + for i := 0; i < int(extra); i++ { 6299 + { 6300 + var maj byte 6301 + var extra uint64 6302 + var err error 6303 + _ = maj 6304 + _ = extra 6305 + _ = err 6306 + 6307 + { 6308 + 6309 + b, err := cr.ReadByte() 6310 + if err != nil { 6311 + return err 6312 + } 6313 + if b != cbg.CborNull[0] { 6314 + if err := cr.UnreadByte(); err != nil { 6315 + return err 6316 + } 6317 + t.Badges[i] = new(BadgeDisplay_BadgeSelection) 6318 + if err := t.Badges[i].UnmarshalCBOR(cr); err != nil { 6319 + return xerrors.Errorf("unmarshaling t.Badges[i] pointer: %w", err) 6320 + } 6321 + } 6322 + 6323 + } 6324 + 6325 + } 6326 + } 6327 + 6328 + default: 6329 + // Field doesn't exist on this type, so ignore it 6330 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6331 + return err 6332 + } 6333 + } 6334 + } 6335 + 6336 + return nil 6337 + } 6338 + func (t *BadgeDisplay_BadgeSelection) MarshalCBOR(w io.Writer) error { 6339 + if t == nil { 6340 + _, err := w.Write(cbg.CborNull) 6341 + return err 6342 + } 6343 + 6344 + cw := cbg.NewCborWriter(w) 6345 + fieldCount := 2 6346 + 6347 + if t.Issuance == nil { 6348 + fieldCount-- 6349 + } 6350 + 6351 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6352 + return err 6353 + } 6354 + 6355 + // t.Issuance (string) (string) 6356 + if t.Issuance != nil { 6357 + 6358 + if len("issuance") > 1000000 { 6359 + return xerrors.Errorf("Value in field \"issuance\" was too long") 6360 + } 6361 + 6362 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issuance"))); err != nil { 6363 + return err 6364 + } 6365 + if _, err := cw.WriteString(string("issuance")); err != nil { 6366 + return err 6367 + } 6368 + 6369 + if t.Issuance == nil { 6370 + if _, err := cw.Write(cbg.CborNull); err != nil { 6371 + return err 6372 + } 6373 + } else { 6374 + if len(*t.Issuance) > 1000000 { 6375 + return xerrors.Errorf("Value in field t.Issuance was too long") 6376 + } 6377 + 6378 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Issuance))); err != nil { 6379 + return err 6380 + } 6381 + if _, err := cw.WriteString(string(*t.Issuance)); err != nil { 6382 + return err 6383 + } 6384 + } 6385 + } 6386 + 6387 + // t.BadgeType (string) (string) 6388 + if len("badgeType") > 1000000 { 6389 + return xerrors.Errorf("Value in field \"badgeType\" was too long") 6390 + } 6391 + 6392 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { 6393 + return err 6394 + } 6395 + if _, err := cw.WriteString(string("badgeType")); err != nil { 6396 + return err 6397 + } 6398 + 6399 + if len(t.BadgeType) > 1000000 { 6400 + return xerrors.Errorf("Value in field t.BadgeType was too long") 6401 + } 6402 + 6403 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { 6404 + return err 6405 + } 6406 + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { 6407 + return err 6408 + } 6409 + return nil 6410 + } 6411 + 6412 + func (t *BadgeDisplay_BadgeSelection) UnmarshalCBOR(r io.Reader) (err error) { 6413 + *t = BadgeDisplay_BadgeSelection{} 6414 + 6415 + cr := cbg.NewCborReader(r) 6416 + 6417 + maj, extra, err := cr.ReadHeader() 6418 + if err != nil { 6419 + return err 6420 + } 6421 + defer func() { 6422 + if err == io.EOF { 6423 + err = io.ErrUnexpectedEOF 6424 + } 6425 + }() 6426 + 6427 + if maj != cbg.MajMap { 6428 + return fmt.Errorf("cbor input should be of type map") 6429 + } 6430 + 6431 + if extra > cbg.MaxLength { 6432 + return fmt.Errorf("BadgeDisplay_BadgeSelection: map struct too large (%d)", extra) 6433 + } 6434 + 6435 + n := extra 6436 + 6437 + nameBuf := make([]byte, 9) 6438 + for i := uint64(0); i < n; i++ { 6439 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6440 + if err != nil { 6441 + return err 6442 + } 6443 + 6444 + if !ok { 6445 + // Field doesn't exist on this type, so ignore it 6446 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6447 + return err 6448 + } 6449 + continue 6450 + } 6451 + 6452 + switch string(nameBuf[:nameLen]) { 6453 + // t.Issuance (string) (string) 6454 + case "issuance": 6455 + 6456 + { 6457 + b, err := cr.ReadByte() 6458 + if err != nil { 6459 + return err 6460 + } 6461 + if b != cbg.CborNull[0] { 6462 + if err := cr.UnreadByte(); err != nil { 6463 + return err 6464 + } 6465 + 6466 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6467 + if err != nil { 6468 + return err 6469 + } 6470 + 6471 + t.Issuance = (*string)(&sval) 6472 + } 6473 + } 6474 + // t.BadgeType (string) (string) 6475 + case "badgeType": 6476 + 6477 + { 6478 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6479 + if err != nil { 6480 + return err 6481 + } 6482 + 6483 + t.BadgeType = string(sval) 6484 + } 6485 + 6486 + default: 6487 + // Field doesn't exist on this type, so ignore it 6488 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6489 + return err 6490 + } 6491 + } 6492 + } 6493 + 6494 + return nil 6495 + }
+4 -2
pkg/streamplace/chatdefs.go
··· 16 16 type ChatDefs_MessageView struct { 17 17 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` 18 18 Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` 19 - ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 20 - Cid string `json:"cid" cborgen:"cid"` 19 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. 20 + Badges []*BadgeDefs_BadgeView `json:"badges,omitempty" cborgen:"badges,omitempty"` 21 + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 22 + Cid string `json:"cid" cborgen:"cid"` 21 23 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 24 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 25 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+3
pnpm-lock.yaml
··· 479 479 expo-video: 480 480 specifier: ^2.0.0 481 481 version: 2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 482 + graphemer: 483 + specifier: ^1.4.0 484 + version: 1.4.0 482 485 hls.js: 483 486 specifier: ^1.5.17 484 487 version: 1.5.17