Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+3467 -1134
+6
.prettierrc
··· 6 6 "options": { 7 7 "proseWrap": "preserve" 8 8 } 9 + }, 10 + { 11 + "files": "*.md", 12 + "options": { 13 + "proseWrap": "preserve" 14 + } 9 15 } 10 16 ], 11 17 "plugins": ["prettier-plugin-organize-imports"]
+1 -1
Makefile
··· 438 438 .PHONY: ci-lexicons 439 439 ci-lexicons: 440 440 $(MAKE) lexicons \ 441 - && 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 442 442 443 443 # _______ ______ _____ _______ _____ _ _ _____ 444 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
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}
+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.6", 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 () => {
+15
js/app/utils/clear-query-params.ts
··· 1 + import { Platform } from "react-native"; 2 + 3 + export default function clearQueryParams(par = ["iss", "state", "code"]) { 4 + if (Platform.OS !== "web") { 5 + return; 6 + } 7 + const u = new URL(document.location.href); 8 + const params = new URLSearchParams(u.search); 9 + if (u.search === "") { 10 + return; 11 + } 12 + par.forEach((p) => params.delete(p)); 13 + u.search = params.toString(); 14 + window.history.replaceState(null, "", u.toString()); 15 + }
+3 -6
js/atproto-oauth-client-react-native/README.md
··· 87 87 forwarded the port with `adb reverse`. For testing on iOS hardware, you'll 88 88 instead need to set up TLS. 89 89 90 - [react-native-quick-crypto]: 91 - https://github.com/margelo/react-native-quick-crypto 90 + [react-native-quick-crypto]: https://github.com/margelo/react-native-quick-crypto 92 91 [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ 93 - [README]: 94 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 95 - [example]: 96 - https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example 92 + [README]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser 93 + [example]: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
+1 -1
js/atproto-oauth-client-react-native/package.json
··· 1 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": [
+8
js/components/locales/en-US/chat.ftl
··· 1 + censored-text-hide = Hide Text 2 + censored-text-reveal = Reveal Text 3 + censored-text-blocked-with-reasons = This text was blocked because of these reasons: { $reasons } 4 + censored-text-blocked-unknown = This text was blocked for an unknown reason 5 + 6 + category-discriminatory = Discriminatory content 7 + category-sexually-explicit = Sexually explicit content 8 + category-profanity = Profanity
+13 -1
js/components/locales/en-US/common.ftl
··· 51 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>
+8
js/components/locales/es-ES/chat.ftl
··· 1 + censored-text-hide = Ocultar texto 2 + censored-text-reveal = Revelar texto 3 + censored-text-blocked-with-reasons = Este texto fue bloqueado por estas razones: { $reasons } 4 + censored-text-blocked-unknown = Este texto fue bloqueado por un motivo desconocido 5 + 6 + category-discriminatory = Contenido discriminatorio 7 + category-sexually-explicit = Contenido sexualmente explícito 8 + category-profanity = Blasfemia
+8
js/components/locales/fr-FR/chat.ftl
··· 1 + censored-text-hide = Masquer le texte 2 + censored-text-reveal = Révéler le texte 3 + censored-text-blocked-with-reasons = Ce texte a été bloqué pour ces raisons : { $reasons } 4 + censored-text-blocked-unknown = Ce texte a été bloqué pour une raison inconnue 5 + 6 + category-discriminatory = Contenu discriminatoire 7 + category-sexually-explicit = Contenu sexuellement explicite 8 + category-profanity = Blasphème
+8
js/components/locales/pt-BR/chat.ftl
··· 1 + censored-text-hide = Ocultar texto 2 + censored-text-reveal = Revelar texto 3 + censored-text-blocked-with-reasons = Este texto foi bloqueado por estes motivos: { $reasons } 4 + censored-text-blocked-unknown = Este texto foi bloqueado por um motivo desconhecido 5 + 6 + category-discriminatory = Conteúdo discriminatório 7 + category-sexually-explicit = Conteúdo sexualmente explícito 8 + category-profanity = Profanidade
+8
js/components/locales/zh-Hant/chat.ftl
··· 1 + censored-text-hide = 隱藏文字 2 + censored-text-reveal = 顯示文字 3 + censored-text-blocked-with-reasons = 此文字因以下原因被封鎖:{ $reasons } 4 + censored-text-blocked-unknown = 此文字因未知原因被封鎖 5 + 6 + category-discriminatory = 歧視性內容 7 + category-sexually-explicit = 色情內容 8 + category-profanity = 粗俗語言
+2 -1
js/components/package.json
··· 1 1 { 2 2 "name": "@streamplace/components", 3 - "version": "0.9.6", 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",
+88
js/components/src/components/chat/censored-text.tsx
··· 1 + import { TriggerRef } from "@rn-primitives/dropdown-menu"; 2 + import { useEffect, useRef, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { useChatFilters } from "../../streamplace-store"; 5 + import { 6 + DropdownMenu, 7 + DropdownMenuGroup, 8 + DropdownMenuInfo, 9 + DropdownMenuItem, 10 + DropdownMenuTrigger, 11 + ResponsiveDropdownMenuContent, 12 + } from "../ui/dropdown"; 13 + import { Text } from "../ui/text"; 14 + import { ChatFilterCategory } from "./chat-settings"; 15 + 16 + function getCategoryKey(category: string): string { 17 + const categoryMap: Record<string, string> = { 18 + "place.stream.richtext.defs#discriminatory": "category-discriminatory", 19 + "place.stream.richtext.defs#sexually_explicit": 20 + "category-sexually-explicit", 21 + "place.stream.richtext.defs#profanity": "category-profanity", 22 + }; 23 + return categoryMap[category] || category; 24 + } 25 + 26 + export function CensoredText({ 27 + text, 28 + reasoning, 29 + }: { 30 + text: string; 31 + reasoning?: string[]; 32 + }) { 33 + const filters = useChatFilters(); 34 + const { t } = useTranslation("chat"); 35 + const hasFilterMatch = reasoning?.some((r) => 36 + filters.has(r as ChatFilterCategory), 37 + ); 38 + const [revealed, setRevealed] = useState(!hasFilterMatch); 39 + const dropdownRef = useRef<TriggerRef>(null); 40 + const handleOpenDropdown = () => { 41 + dropdownRef.current?.open(); 42 + }; 43 + 44 + const translatedReasons = reasoning?.map((r) => t(getCategoryKey(r))); 45 + 46 + // update when filters change 47 + useEffect(() => { 48 + const match = reasoning?.some((r) => filters.has(r as ChatFilterCategory)); 49 + if (match) { 50 + setRevealed(false); 51 + } else { 52 + setRevealed(true); 53 + } 54 + }, [filters]); 55 + 56 + return ( 57 + <> 58 + <Text 59 + color={revealed ? "default" : "primary"} 60 + style={{ display: "inline" as any }} 61 + onPress={handleOpenDropdown} 62 + > 63 + {revealed ? text : text.replace(/./g, "*")} 64 + </Text> 65 + <DropdownMenu> 66 + <DropdownMenuTrigger ref={dropdownRef}></DropdownMenuTrigger> 67 + <ResponsiveDropdownMenuContent> 68 + <DropdownMenuGroup> 69 + <DropdownMenuItem onPress={() => setRevealed(!revealed)}> 70 + <Text> 71 + {revealed ? t("censored-text-hide") : t("censored-text-reveal")} 72 + </Text> 73 + </DropdownMenuItem> 74 + </DropdownMenuGroup> 75 + <DropdownMenuInfo 76 + description={ 77 + translatedReasons 78 + ? t("censored-text-blocked-with-reasons", { 79 + reasons: translatedReasons.join(", "), 80 + }) 81 + : t("censored-text-blocked-unknown") 82 + } 83 + /> 84 + </ResponsiveDropdownMenuContent> 85 + </DropdownMenu> 86 + </> 87 + ); 88 + }
+44 -3
js/components/src/components/chat/chat-box.tsx
··· 1 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 { 9 + Button, 10 + ChatSettings, 11 + Loader, 12 + Text, 13 + toast, 14 + useTheme, 15 + View, 16 + } from "../../"; 8 17 import { handleSlashCommand } from "../../lib/slash-commands"; 9 18 import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; 10 19 import { StreamNotifications } from "../../lib/stream-notifications"; ··· 30 39 useReplyToMessage, 31 40 useSetReplyToMessage, 32 41 } from "../../livestream-store"; 33 - import { useDID, usePDSAgent } from "../../streamplace-store"; 42 + import { 43 + useDID, 44 + usePDSAgent, 45 + useSetChatFilters, 46 + } from "../../streamplace-store"; 34 47 import { Textarea } from "../ui/textarea"; 35 48 import { RenderChatMessage } from "./chat-message"; 36 49 import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; ··· 40 53 // @ts-ignore we can iterate through this just fine it seems 41 54 ..."😀🥸😍😘😁🥸😆🥸😜🥸😂😅🥸🙂🤫😱🥸🤣😗😄🥸😎🤓😲😯😰🥸😥🥸😣🥸😞😓🥸😩😩🥸😤🥱", 42 55 ]; 56 + 57 + const graphemer = new Graphemer(); 43 58 44 59 export function ChatBox({ 45 60 isPopout, ··· 65 80 new Map(), 66 81 ); 67 82 const [filteredEmojis, setFilteredEmojis] = useState<any[]>([]); 83 + const isOverLimit = graphemer.countGraphemes(message) > 300; 68 84 69 85 let linfo = useLivestream(); 86 + const setChatFilters = useSetChatFilters(); 70 87 71 88 const { theme, zero: zt } = useTheme(); 72 89 ··· 255 272 256 273 const submit = async () => { 257 274 if (!message.trim()) return; 275 + if (graphemer.countGraphemes(message) > 300) { 276 + toast.show( 277 + "Message too long", 278 + "Please limit your message to 300 characters.", 279 + { 280 + variant: "error", 281 + duration: 3, 282 + }, 283 + ); 284 + return; 285 + } 258 286 259 287 const messageText = message; 260 288 setMessage(""); ··· 457 485 } 458 486 } 459 487 }} 460 - style={[chatBoxStyle]} 488 + style={[ 489 + chatBoxStyle, 490 + isOverLimit && { 491 + borderColor: "#ef4444", 492 + borderWidth: 2, 493 + outline: "none", 494 + }, 495 + ]} 461 496 // "submit" won't blur on enter 462 497 submitBehavior="submit" 463 498 placeholder="Type a message..." ··· 473 508 {submitting ? <Loader /> : "Send"} 474 509 </Button> 475 510 </View> 511 + {Platform.OS !== "web" && ( 512 + <ChatSettings onFiltersChange={setChatFilters} /> 513 + )} 476 514 </View> 477 515 {showSuggestions && ( 478 516 <MentionSuggestions ··· 562 600 > 563 601 <ExternalLink color={theme.colors.primaryForeground} size={16} /> 564 602 </Button> 603 + )} 604 + {Platform.OS === "web" && ( 605 + <ChatSettings onFiltersChange={setChatFilters} /> 565 606 )} 566 607 </View> 567 608 )}
+15 -1
js/components/src/components/chat/chat-message.tsx
··· 25 25 26 26 import { useLivestreamStore } from "../../livestream-store"; 27 27 import { Text } from "../ui/text"; 28 + import { CensoredText } from "./censored-text"; 28 29 29 30 const getRgbColor = (color?: { red: number; green: number; blue: number }) => 30 31 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : colors.gray[500]; ··· 67 68 {obj.text} 68 69 </Text> 69 70 ); 71 + } else if (ftr.$type === "place.stream.richtext.defs#censor") { 72 + let censorFtr = ftr as any; 73 + return ( 74 + <CensoredText 75 + key={`censor-facet-${index}`} 76 + text={obj.text} 77 + reasoning={censorFtr.categories} 78 + /> 79 + ); 70 80 } else { 71 81 // render as normal text if we don't recognize the facet type 72 82 return <Text key={`unknown-facet-${index}`}>{obj.text}</Text>; ··· 91 101 92 102 return segs.map((seg, i) => segmentedObject(seg, i, userCache)); 93 103 }; 104 + 94 105 export const RenderChatMessage = memo( 95 106 function RenderChatMessage({ 96 107 item, ··· 147 158 fontStyle: "italic", 148 159 }} 149 160 > 150 - {replyTo.record.text} 161 + <RichTextMessage 162 + text={replyTo.record.text} 163 + facets={replyTo.record.facets || []} 164 + /> 151 165 </Text> 152 166 </Text> 153 167 </View>
+147
js/components/src/components/chat/chat-settings.tsx
··· 1 + import { EllipsisVertical } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import { Platform, Pressable, View } from "react-native"; 4 + import { Button, zero } from "../.."; 5 + import { 6 + ChatFilterCategory, 7 + useChatFilters, 8 + useSetChatFilters, 9 + } from "../../streamplace-store"; 10 + import { useTheme } from "../../ui"; 11 + import { 12 + DropdownMenu, 13 + DropdownMenuCheckboxItem, 14 + DropdownMenuGroup, 15 + DropdownMenuInfo, 16 + DropdownMenuItem, 17 + DropdownMenuSeparator, 18 + DropdownMenuSub, 19 + DropdownMenuSubContent, 20 + DropdownMenuSubTrigger, 21 + DropdownMenuTrigger, 22 + ResponsiveDropdownMenuContent, 23 + } from "../ui/dropdown"; 24 + import { Text } from "../ui/text"; 25 + 26 + export type { ChatFilterCategory }; 27 + 28 + interface ChatSettingsProps { 29 + onFiltersChange?: (filters: Set<ChatFilterCategory>) => void; 30 + } 31 + 32 + const CATEGORY_LABELS: Record<ChatFilterCategory, string> = { 33 + "place.stream.richtext.defs#discriminatory": "Discriminatory", 34 + "place.stream.richtext.defs#sexually_explicit": "Sexually Explicit", 35 + "place.stream.richtext.defs#profanity": "Profanity", 36 + }; 37 + 38 + const ALL_CATEGORIES: ChatFilterCategory[] = [ 39 + "place.stream.richtext.defs#discriminatory", 40 + "place.stream.richtext.defs#sexually_explicit", 41 + "place.stream.richtext.defs#profanity", 42 + ]; 43 + 44 + export function ChatSettings({ onFiltersChange }: ChatSettingsProps) { 45 + const { icons } = useTheme(); 46 + const storedFilters = useChatFilters(); 47 + const setStoredFilters = useSetChatFilters(); 48 + const [filters, setFilters] = 49 + useState<Set<ChatFilterCategory>>(storedFilters); 50 + 51 + const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 52 + 53 + // Sync local state with stored filters on mount and when stored filters change 54 + useEffect(() => { 55 + setFilters(storedFilters); 56 + }, [storedFilters]); 57 + 58 + const toggleFilter = (category: ChatFilterCategory) => { 59 + const newFilters = new Set(filters); 60 + if (newFilters.has(category)) { 61 + newFilters.delete(category); 62 + } else { 63 + newFilters.add(category); 64 + } 65 + setFilters(newFilters); 66 + setStoredFilters(newFilters); 67 + onFiltersChange?.(newFilters); 68 + }; 69 + 70 + const allFiltersEnabled = filters.size === ALL_CATEGORIES.length; 71 + 72 + const toggleAllFilters = () => { 73 + const newFilters = allFiltersEnabled 74 + ? new Set<ChatFilterCategory>() 75 + : new Set(ALL_CATEGORIES); 76 + setFilters(newFilters); 77 + setStoredFilters(newFilters); 78 + onFiltersChange?.(newFilters); 79 + }; 80 + 81 + return ( 82 + <DropdownMenu> 83 + <DropdownMenuTrigger> 84 + <Pressable> 85 + {({ pressed }) => ( 86 + <Button 87 + variant="ghost" 88 + aria-label="Popout Chat" 89 + style={{ borderRadius: 16, maxHeight: 44, aspectRatio: 0.5 }} 90 + > 91 + <EllipsisVertical size={20} color={icons.color.muted} /> 92 + </Button> 93 + )} 94 + </Pressable> 95 + </DropdownMenuTrigger> 96 + <ResponsiveDropdownMenuContent align="end"> 97 + <DropdownMenuGroup title="Chat Settings"> 98 + <DropdownMenuSub> 99 + <DropdownMenuSubTrigger subMenuTitle="Chat Filters"> 100 + <View 101 + style={[ 102 + zero.flex.values[1], 103 + isMobile ? zero.layout.flex.row : zero.layout.flex.column, 104 + zero.layout.flex.spaceBetween, 105 + zero.pr[4], 106 + ]} 107 + > 108 + <Text>Chat Filters</Text> 109 + </View> 110 + </DropdownMenuSubTrigger> 111 + <DropdownMenuSubContent> 112 + <DropdownMenuGroup title="Content Filters"> 113 + <DropdownMenuItem onPress={toggleAllFilters}> 114 + <Text> 115 + {allFiltersEnabled ? "Disable All" : "Enable All"} 116 + </Text> 117 + </DropdownMenuItem> 118 + </DropdownMenuGroup> 119 + <DropdownMenuGroup> 120 + {( 121 + Object.entries(CATEGORY_LABELS) as [ 122 + ChatFilterCategory, 123 + string, 124 + ][] 125 + ).map(([category, label], i) => ( 126 + <> 127 + <DropdownMenuCheckboxItem 128 + key={category} 129 + checked={filters.has(category)} 130 + onCheckedChange={() => toggleFilter(category)} 131 + > 132 + <Text>{label}</Text> 133 + </DropdownMenuCheckboxItem> 134 + {i < Object.entries(CATEGORY_LABELS).length - 1 && ( 135 + <DropdownMenuSeparator /> 136 + )} 137 + </> 138 + ))} 139 + </DropdownMenuGroup> 140 + <DropdownMenuInfo description="Hide messages containing content that may be inappropriate or offensive by category." /> 141 + </DropdownMenuSubContent> 142 + </DropdownMenuSub> 143 + </DropdownMenuGroup> 144 + </ResponsiveDropdownMenuContent> 145 + </DropdownMenu> 146 + ); 147 + }
+2 -2
js/components/src/components/chat/chat.tsx
··· 261 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 }
+13 -11
js/components/src/components/ui/dropdown.tsx
··· 537 537 ({ description, ...props }, ref) => { 538 538 const { theme } = useTheme(); 539 539 return ( 540 - <Text 541 - style={[ 542 - { color: theme.colors.textMuted }, 543 - pt[1], 544 - pl[2], 545 - pb[2], 546 - fontSize.sm, 547 - ]} 548 - > 549 - {description} 550 - </Text> 540 + <View ref={ref} {...props}> 541 + <Text 542 + style={[ 543 + { color: theme.colors.textMuted }, 544 + pt[1], 545 + pl[2], 546 + pb[2], 547 + fontSize.sm, 548 + ]} 549 + > 550 + {description} 551 + </Text> 552 + </View> 551 553 ); 552 554 }, 553 555 );
+2
js/components/src/components/ui/textarea.tsx
··· 40 40 { borderRadius: 10 }, 41 41 style, 42 42 ]} 43 + autoComplete={props.autoComplete || "off"} 44 + textContentType={props.textContentType || "none"} 43 45 multiline={multiline} 44 46 numberOfLines={numberOfLines} 45 47 textAlignVertical="top"
+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 }
+10
js/components/src/i18n/i18n-loader.native.ts
··· 2 2 // Metro will use this file for React Native builds 3 3 4 4 // Import all translations directly so they're bundled into the app 5 + import enUSChat from "../../public/locales/en-US/chat.json"; 5 6 import enUSCommon from "../../public/locales/en-US/common.json"; 6 7 import enUSSettings from "../../public/locales/en-US/settings.json"; 8 + import esESChat from "../../public/locales/es-ES/chat.json"; 7 9 import esESCommon from "../../public/locales/es-ES/common.json"; 8 10 import esESSettings from "../../public/locales/es-ES/settings.json"; 11 + import frFRChat from "../../public/locales/fr-FR/chat.json"; 9 12 import frFRCommon from "../../public/locales/fr-FR/common.json"; 10 13 import frFRSettings from "../../public/locales/fr-FR/settings.json"; 14 + import ptBRChat from "../../public/locales/pt-BR/chat.json"; 11 15 import ptBRCommon from "../../public/locales/pt-BR/common.json"; 12 16 import ptBRSettings from "../../public/locales/pt-BR/settings.json"; 17 + import zhHantChat from "../../public/locales/zh-Hant/chat.json"; 13 18 import zhHantCommon from "../../public/locales/zh-Hant/common.json"; 14 19 import zhHantSettings from "../../public/locales/zh-Hant/settings.json"; 15 20 16 21 const translationMap: Record<string, any> = { 22 + "en-US/chat": enUSChat, 17 23 "en-US/common": enUSCommon, 18 24 "en-US/settings": enUSSettings, 25 + "pt-BR/chat": ptBRChat, 19 26 "pt-BR/common": ptBRCommon, 20 27 "pt-BR/settings": ptBRSettings, 28 + "es-ES/chat": esESChat, 21 29 "es-ES/common": esESCommon, 22 30 "es-ES/settings": esESSettings, 31 + "zh-Hant/chat": zhHantChat, 23 32 "zh-Hant/common": zhHantCommon, 24 33 "zh-Hant/settings": zhHantSettings, 34 + "fr-FR/chat": frFRChat, 25 35 "fr-FR/common": frFRCommon, 26 36 "fr-FR/settings": frFRSettings, 27 37 };
+1 -1
js/components/src/i18n/i18next-config.ts
··· 116 116 117 117 export const I18NEXT_CONFIG = { 118 118 lng: LOCALE, 119 - ns: ["common", "settings"], // Common should be first as it's most frequently used 119 + ns: ["common", "settings", "chat"], // Common should be first as it's most frequently used 120 120 defaultNS: "common", 121 121 interpolation: { 122 122 escapeValue: false, // React already safes from XSS
+1
js/components/src/index.tsx
··· 33 33 34 34 export * from "./components/chat/chat"; 35 35 export * from "./components/chat/chat-box"; 36 + export * from "./components/chat/chat-settings"; 36 37 export * from "./components/chat/system-message"; 37 38 export * from "./components/chat/update-stream-title-dialog"; 38 39 export { default as VideoRetry } from "./components/mobile-player/video-retry";
+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
+39
js/components/src/streamplace-store/streamplace-store.tsx
··· 76 76 setDanmuSpeed: (speed: number) => void; 77 77 setDanmuLaneCount: (laneCount: number) => void; 78 78 setDanmuMaxMessages: (maxMessages: number) => void; 79 + 80 + // Chat filter settings 81 + chatFilters: Set<ChatFilterCategory>; 82 + setChatFilters: (filters: Set<ChatFilterCategory>) => void; 79 83 } 84 + 85 + export type ChatFilterCategory = 86 + | "place.stream.richtext.defs#discriminatory" 87 + | "place.stream.richtext.defs#sexually_explicit" 88 + | "place.stream.richtext.defs#profanity"; 80 89 81 90 export type StreamplaceStore = StoreApi<StreamplaceState>; 82 91 ··· 93 102 const DANMU_SPEED_KEY = "danmuSpeed"; 94 103 const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; 95 104 const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages"; 105 + const CHAT_FILTERS_KEY = "chatFilters"; 96 106 97 107 const store = createStore<StreamplaceState>()((set) => ({ 98 108 url, ··· 210 220 set({ danmuMaxMessages: clamped }); 211 221 storage 212 222 .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) 223 + .catch(console.error); 224 + }, 225 + 226 + // Chat filter settings - start with defaults 227 + chatFilters: new Set(), 228 + 229 + setChatFilters: (filters: Set<ChatFilterCategory>) => { 230 + set({ chatFilters: filters }); 231 + storage 232 + .setItem(CHAT_FILTERS_KEY, JSON.stringify(Array.from(filters))) 213 233 .catch(console.error); 214 234 }, 215 235 })); ··· 227 247 const storedDanmuMaxMessages = await storage.getItem( 228 248 DANMU_MAX_MESSAGES_KEY, 229 249 ); 250 + const storedChatFilters = await storage.getItem(CHAT_FILTERS_KEY); 230 251 231 252 let initialVolume = 1.0; 232 253 let initialMuted = false; ··· 236 257 let initialDanmuSpeed = 1; 237 258 let initialDanmuLaneCount = 12; 238 259 let initialDanmuMaxMessages = 50; 260 + let initialChatFilters = new Set<ChatFilterCategory>(); 239 261 240 262 if (storedVolume) { 241 263 const parsedVolume = parseFloat(storedVolume); ··· 288 310 } 289 311 } 290 312 313 + if (storedChatFilters) { 314 + try { 315 + const parsed = JSON.parse(storedChatFilters); 316 + if (Array.isArray(parsed)) { 317 + initialChatFilters = new Set(parsed); 318 + } 319 + } catch (error) { 320 + console.error("Failed to parse stored chat filters:", error); 321 + } 322 + } 323 + 291 324 store.setState({ 292 325 volume: initialVolume, 293 326 muted: initialMuted, ··· 297 330 danmuSpeed: initialDanmuSpeed, 298 331 danmuLaneCount: initialDanmuLaneCount, 299 332 danmuMaxMessages: initialDanmuMaxMessages, 333 + chatFilters: initialChatFilters, 300 334 }); 301 335 } catch (error) { 302 336 console.error("Failed to load state from storage:", error); ··· 409 443 setDanmuMaxMessages, 410 444 }; 411 445 }; 446 + 447 + // Chat filter convenience hooks 448 + export const useChatFilters = () => useStreamplaceStore((x) => x.chatFilters); 449 + export const useSetChatFilters = () => 450 + useStreamplaceStore((x) => x.setChatFilters); 412 451 413 452 export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";
+63 -36
js/docs/astro.config.mjs
··· 2 2 import starlight from "@astrojs/starlight"; 3 3 import { defineConfig, passthroughImageService } from "astro/config"; 4 4 import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi"; 5 + import starlightSidebarSwipe from "starlight-sidebar-swipe"; 6 + import starlightSidebarTopics from "starlight-sidebar-topics"; 5 7 6 8 // https://astro.build/config 7 9 export default defineConfig({ ··· 32 34 }, 33 35 favicon: "/favicon.ico", 34 36 plugins: [ 37 + //starlightLinksValidator(), 38 + starlightSidebarSwipe(), 35 39 starlightOpenAPI([ 36 40 { 37 - base: "api", 41 + base: "/api", 38 42 label: "Related XRPC API endpoints", 39 43 schema: "./src/content/docs/lex-reference/openapi.json", // or your json generated from swagger 40 44 sidebar: { ··· 45 49 }, 46 50 }, 47 51 ]), 48 - ], 49 - sidebar: [ 50 - { label: "← Back to Streamplace", link: "/back-to-home" }, 51 - { 52 - label: "How Streamplace Works (Blog)", 53 - link: "https://blog.stream.place/", 54 - attrs: { target: "_blank" }, 55 - }, 56 - { 57 - label: "Guides", 58 - items: [ 52 + starlightSidebarTopics( 53 + [ 59 54 { 60 - label: "Start Streaming", 61 - autogenerate: { directory: "guides/start-streaming" }, 55 + label: "For Streamers & Viewers", 56 + link: "/", 57 + icon: "open-book", 58 + items: [ 59 + { 60 + label: "Start Streaming", 61 + autogenerate: { directory: "guides/start-streaming" }, 62 + }, 63 + { 64 + label: "Features", 65 + autogenerate: { directory: "features" }, 66 + }, 67 + ], 62 68 }, 63 69 { 64 - label: "Installing Streamplace", 65 - autogenerate: { directory: "guides/installing" }, 70 + label: "For Developers", 71 + link: "/developers/", 72 + icon: "seti:config", 73 + id: "developers", 74 + items: [ 75 + { 76 + label: "Start Contributing", 77 + autogenerate: { directory: "guides/start-contributing" }, 78 + }, 79 + { 80 + label: "Installing Streamplace", 81 + autogenerate: { directory: "guides/installing" }, 82 + }, 83 + { 84 + label: "Video Metadata", 85 + autogenerate: { directory: "video-metadata" }, 86 + }, 87 + { 88 + label: "Components", 89 + autogenerate: { directory: "components" }, 90 + }, 91 + { 92 + label: "Localize Streamplace", 93 + autogenerate: { directory: "guides/localizing" }, 94 + }, 95 + ], 66 96 }, 67 97 { 68 - label: "Start Contributing", 69 - autogenerate: { directory: "guides/start-contributing" }, 98 + label: "API Reference", 99 + link: "/reference/", 100 + icon: "seti:json", 101 + id: "ref", 102 + items: [ 103 + { 104 + label: "Lexicon Reference", 105 + autogenerate: { directory: "lex-reference" }, 106 + }, 107 + ...openAPISidebarGroups, 108 + ], 70 109 }, 71 110 ], 72 - }, 73 - { 74 - label: "Features", 75 - autogenerate: { directory: "features" }, 76 - }, 77 - { 78 - label: "Video Metadata", 79 - autogenerate: { directory: "video-metadata" }, 80 - }, 81 - { 82 - label: "Components", 83 - autogenerate: { directory: "components" }, 84 - }, 85 - { 86 - label: "Lexicon Reference", 87 - autogenerate: { directory: "lex-reference" }, 88 - }, 89 - ...openAPISidebarGroups, 111 + { 112 + topics: { 113 + ref: ["/api", "/api/**/*"], 114 + }, 115 + }, 116 + ), 90 117 ], 91 118 }), 92 119 ],
+8 -2
js/docs/package.json
··· 1 1 { 2 2 "name": "streamplace-docs", 3 3 "type": "module", 4 - "version": "0.9.6", 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", ··· 15 15 "@streamplace/app": "workspace:*", 16 16 "astro": "^5.6.1", 17 17 "sharp": "^0.32.5", 18 + "starlight-links-validator": "^0.19.2", 18 19 "starlight-openapi": "^0.17.0", 19 20 "starlight-openapi-rapidoc": "^0.8.1-beta", 21 + "starlight-sidebar-swipe": "^0.1.1", 20 22 "streamplace": "workspace:*" 21 - } 23 + }, 24 + "devDependencies": { 25 + "starlight-sidebar-topics": "^0.6.2" 26 + }, 27 + "private": true 22 28 }
+60
js/docs/src/components/HelpDesk.astro
··· 1 + --- 2 + import { Card, CardGrid } from "@astrojs/starlight/components"; 3 + 4 + interface Props { 5 + searchPlaceholder?: string; 6 + } 7 + --- 8 + 9 + <div class="helpdesk"> 10 + 11 + <h2>How can we help?</h2> 12 + <p>Search the knowledge base, or check out topics below.</p> 13 + 14 + <CardGrid> 15 + <Card title="Getting Started" icon="rocket"> 16 + <p>New to Streamplace? Start here to set up your first stream.</p> 17 + <ul> 18 + <li><a href="/docs/guides/start-streaming/quick-start">Quick start guide</a></li> 19 + <li><a href="/docs/guides/start-streaming/obs">Stream with OBS</a></li> 20 + </ul> 21 + </Card> 22 + 23 + <Card title="Developers & Self-Hosters" icon="laptop"> 24 + <p>Building with Streamplace or running your own node?</p> 25 + <ul> 26 + <li><a href="/docs/developers">Developer documentation</a></li> 27 + </ul> 28 + </Card> 29 + </CardGrid> 30 + </div> 31 + 32 + <style> 33 + .helpdesk { 34 + margin: 0 auto; 35 + } 36 + 37 + .helpdesk-search { 38 + margin-bottom: 2rem; 39 + } 40 + 41 + .search-input { 42 + width: 100%; 43 + padding: 1rem 1.5rem; 44 + font-size: 1.125rem; 45 + border: 2px solid var(--sl-color-gray-5); 46 + border-radius: 0.5rem; 47 + background: var(--sl-color-bg); 48 + color: var(--sl-color-text); 49 + transition: border-color 0.2s; 50 + } 51 + 52 + .search-input:focus { 53 + outline: none; 54 + border-color: var(--sl-color-accent); 55 + } 56 + 57 + .helpdesk h2 { 58 + margin-bottom: 1.5rem; 59 + } 60 + </style>
+1 -2
js/docs/src/content/docs/components/custom_ui.md
··· 1 1 --- 2 2 title: Creating your own player UI 3 - description: 4 - How to set up your player UI with components from @streamplace/components. 3 + description: How to set up your player UI with components from @streamplace/components. 5 4 --- 6 5 7 6 # Building a Custom Player UI
+40
js/docs/src/content/docs/developers.mdx
··· 1 + --- 2 + title: Developers & Self-Hosters 3 + description: Build with Streamplace or run your own infrastructure. 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + ## Learn how to deploy, or contribute to Streamplace. 10 + 11 + <br /> 12 + 13 + <CardGrid stagger> 14 + <Card title="Building an Application" icon="laptop"> 15 + Integrate live video into your project. - [API 16 + reference](/docs/lex-reference/place-stream-defs) - [Our component 17 + library](/docs/components/custom_ui/) 18 + </Card> 19 + 20 + {" "} 21 + 22 + <Card title="Self-Hosting" icon="seti:config"> 23 + Run your own Streamplace infrastructure. - [Installation 24 + guide](/docs/guides/installing/installing-streamplace) 25 + </Card> 26 + 27 + {" "} 28 + 29 + <Card title="Contributing" icon="github"> 30 + Help improve Streamplace. - [Development 31 + setup](/docs/guides/streamplace-dev-setup) - [Video 32 + signing](/docs/video-metadata/intro/) 33 + </Card> 34 + 35 + <Card title="Support & Community" icon="information"> 36 + Get help and connect with other developers. - [GitHub 37 + issues](https://github.com/streamplace/streamplace/issues) - [Discord 38 + community](https://discord.stream.place) 39 + </Card> 40 + </CardGrid>
+3 -1
js/docs/src/content/docs/features/danmu.md
··· 3 3 description: Add flying bullet-style chat comments to the player, or your stream 4 4 --- 5 5 6 - :::note This feature is experimental and may change in future releases. ::: 6 + :::note 7 + This feature is experimental and may change in future releases. 8 + ::: 7 9 8 10 [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (弹幕, 9 11 "bullet curtain") is a comment style where messages fly across the video
+27
js/docs/src/content/docs/features/embed.md
··· 1 + --- 2 + title: Embedding your livestream 3 + description: How to embed your livestream on your website, blog, etc. 4 + --- 5 + 6 + Streamplace provides an easy way to embed your livestream on any website or 7 + blog. 8 + 9 + You can access the embedded livestream page by putting `/embed` in the URL of 10 + your livestream. For example, if your livestream URL is 11 + `https://stream.place/iame.li`, the embed URL will be 12 + `https://stream.place/embed/iame.li`. 13 + 14 + You can use the following HTML snippet to embed your livestream: 15 + 16 + ```html 17 + <iframe 18 + src="https://stream.place/embed/your-handle" 19 + width="560" 20 + height="315" 21 + frameborder="0" 22 + allowfullscreen 23 + ></iframe> 24 + ``` 25 + 26 + Alternatively, you can use the share sheet located on your livestream page. 27 + Click the "Share" button, and you'll find the embed code ready to copy.
+52
js/docs/src/content/docs/features/multistreaming.md
··· 1 + --- 2 + title: Multistreaming 3 + description: Forward your Streamplace stream to other providers. 4 + --- 5 + 6 + :::note 7 + This guide isn't about setting up Streamplace as an OBS destination. See [OBS Multistreaming to Streamplace](/docs/guides/start-streaming/obs-multistreaming/) for information on that. 8 + ::: 9 + 10 + Multistreaming lets you forward your Streamplace stream to multiple platforms at the same time. Instead of streaming only to Streamplace, you can forward your stream to any platform that accepts RTMP input. 11 + 12 + ## Setting up multistream targets 13 + 14 + 1. Go to **Settings** > **Streaming** > **Multistream Targets** 15 + 2. Click **Create Multistream Target** 16 + 3. Enter the RTMP or RTMPS URL from your destination platform 17 + 4. Optionally give it a name to identify it later 18 + 5. Click **Create** 19 + 20 + ### Finding your multistream URL 21 + 22 + Different platforms will provide their own RTMP URLs. Some common examples: 23 + 24 + - **YouTube Live**: Format `rtmp://a.rtmp.youtube.com/live2/your-stream-key` 25 + - Find your stream key at https://studio.youtube.com/channel/UC/livestreaming (click the copy icon in the top right corner of the 'connect your encoder to go live' box) 26 + - **Twitch**: Format `rtmp://usw20.contribute.live-video.net/app/your-stream-key` 27 + - You can get a valid RTMPS url at https://help.twitch.tv/s/twitch-ingest-recommendation 28 + - Find your stream key at https://dashboard.twitch.tv/settings/stream (your 'primary stream key') 29 + 30 + :::note 31 + Your stream key should automatically be hidden once you confirm. Make sure you've entered it correctly! 32 + ::: 33 + 34 + ## Managing targets during a stream 35 + 36 + When you're live, you can see all your multistream targets on the Live Dashboard with their current status: 37 + 38 + - **Green (Active)**: Successfully streaming to this target 39 + - **Yellow (Pending)**: Connecting to this target 40 + - **Red (Error)**: Connection failed; check your URL and credentials 41 + - **Gray (Inactive)**: This target is disabled 42 + 43 + You can toggle any target on or off with the switch next to its name. Changes take effect immediately. 44 + 45 + ## Limits 46 + 47 + - **Maximum targets**: 100 total per account 48 + - **Maximum active targets**: 5 simultaneous streams 49 + 50 + ### Credits 51 + 52 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+83
js/docs/src/content/docs/features/webhooks.md
··· 1 + --- 2 + title: Discord Webhooks 3 + description: Configure Discord webhooks for livestream announcements and chat 4 + sidebar: 5 + order: 30 6 + --- 7 + 8 + Streamplace supports Discord webhooks for receiving livestream 9 + notifications and chat messages. You can create, manage, and configure webhooks 10 + to customize how events are delivered to your Discord channels. 11 + 12 + ## Webhook Events 13 + 14 + You can configure webhooks to listen for specific events. For right now, the 15 + following events are supported: 16 + 17 + - `Chat`: Triggered when a chat message is sent. 18 + - `Livestream`: Triggered when a livestream starts. 19 + 20 + ## Creating a Webhook 21 + 22 + To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 + navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 + fields are required: 25 + 26 + - Name: Webhook URL. For example, 27 + `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 + - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 + `Livestream Started`). `Livestream Started` is pre-checked by default. 30 + 31 + We'd recommend also filling out these optional fields: 32 + 33 + - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 + you can remember. 35 + - Description: A description of what this webhook is for (e.g., "Sends 36 + livestream start notifications to Discord channel"). 37 + - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 + "[Streamplace] "). Will apply to both Chat and Livestream events! 39 + - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 + live!"). Will apply to both Chat and Livestream events! 41 + - Text replacements: A list of text replacements to apply to chat messages sent 42 + by this webhook. Each replacement consists of a "from" string and a "to" 43 + string. For example, you could replace all instances of "foo" with "bar". 44 + 45 + After filling out the form, click "Create" to save your webhook. You should see 46 + it listed in the "Webhooks" section. 47 + 48 + ## Updating a Webhook 49 + 50 + To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 + navigate to the "Webhooks" section. Find the webhook you want to update and 52 + click on the "pen" icon next to it. This will open the webhook edit form, where 53 + you can modify the fields as needed. After making your changes, click "Update" 54 + to save your changes. 55 + 56 + ## Deleting a Webhook 57 + 58 + To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 + navigate to the "Webhooks" section. Find the webhook you want to delete and 60 + click on the "trash" icon next to it. A confirmation dialog will appear; click 61 + "Delete" to confirm. The webhook will be removed from the list. 62 + 63 + ## Recommendations 64 + 65 + We'd recommend: 66 + 67 + - Creating separate Discord channels for livestream notifications and chat 68 + messages to keep them organized. 69 + - If you want to have one webhook for both chat and livestream events, you can 70 + create multiple webhooks with the same URL but different event subscriptions 71 + and prefixes/suffixes/replacements. 72 + - Testing your webhook by starting a livestream or sending a chat message to 73 + ensure that notifications are being sent correctly. 74 + 75 + ## API Documentation 76 + 77 + See these endpoint pages: 78 + 79 + - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 + - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 + - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 + - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 + - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
+1 -2
js/docs/src/content/docs/guides/start-contributing/styling-quick-reference.md
··· 1 1 --- 2 2 title: ZeroCSS Quick Reference 3 - description: 4 - Quick reference for Streamplace ZeroCSS - common patterns and utilities. 3 + description: Quick reference for Streamplace ZeroCSS - common patterns and utilities. 5 4 sidebar: 6 5 order: 31 7 6 ---
-83
js/docs/src/content/docs/guides/start-streaming/discord-hooks.md
··· 1 - --- 2 - title: Discord Webhooks 3 - description: Configure Discord webhooks for livestream announcements and chat 4 - sidebar: 5 - order: 30 6 - --- 7 - 8 - Streamplace supports Discord webhook integration for receiving livestream 9 - notifications and chat messages. You can create, manage, and configure webhooks 10 - to customize how events are delivered to your Discord channels. 11 - 12 - ## Webhook Events 13 - 14 - You can configure webhooks to listen for specific events. For right now, the 15 - following events are supported: 16 - 17 - - `Chat`: Triggered when a chat message is sent. 18 - - `Livestream`: Triggered when a livestream starts. 19 - 20 - ## Creating a Webhook 21 - 22 - To create a webhook, go to the "Settings" page of the Streamplace web app, then 23 - navigate to the "Webhooks" section. Click on "Create Webhook". The following 24 - fields are required: 25 - 26 - - Name: Webhook URL. For example, 27 - `https://discord.com/api/webhooks/{webhook.id}/{webhook.token}` 28 - - Events: Select the events you want to subscribe to (e.g., `Chat Messages`, 29 - `Livestream Started`). `Livestream Started` is pre-checked by default. 30 - 31 - We'd recommend also filling out these optional fields: 32 - 33 - - Name: A name for the webhook (e.g., "Discord Livestream Notifications") that 34 - you can remember. 35 - - Description: A description of what this webhook is for (e.g., "Sends 36 - livestream start notifications to Discord channel"). 37 - - Prefix: A prefix to add to each message sent by this webhook (e.g., 38 - "[Streamplace] "). Will apply to both Chat and Livestream events! 39 - - Suffix: A suffix to add to each message sent by this webhook (e.g., "is now 40 - live!"). Will apply to both Chat and Livestream events! 41 - - Text replacements: A list of text replacements to apply to chat messages sent 42 - by this webhook. Each replacement consists of a "from" string and a "to" 43 - string. For example, you could replace all instances of "foo" with "bar". 44 - 45 - After filling out the form, click "Create" to save your webhook. You should see 46 - it listed in the "Webhooks" section. 47 - 48 - ## Updating a Webhook 49 - 50 - To update a webhook, go to the "Settings" page of the Streamplace web app, then 51 - navigate to the "Webhooks" section. Find the webhook you want to update and 52 - click on the "pen" icon next to it. This will open the webhook edit form, where 53 - you can modify the fields as needed. After making your changes, click "Update" 54 - to save your changes. 55 - 56 - ## Deleting a Webhook 57 - 58 - To delete a webhook, go to the "Settings" page of the Streamplace web app, then 59 - navigate to the "Webhooks" section. Find the webhook you want to delete and 60 - click on the "trash" icon next to it. A confirmation dialog will appear; click 61 - "Delete" to confirm. The webhook will be removed from the list. 62 - 63 - ## Recommendations 64 - 65 - We'd recommend: 66 - 67 - - Creating separate Discord channels for livestream notifications and chat 68 - messages to keep them organized. 69 - - If you want to have one webhook for both chat and livestream events, you can 70 - create multiple webhooks with the same URL but different event subscriptions 71 - and prefixes/suffixes/replacements. 72 - - Testing your webhook by starting a livestream or sending a chat message to 73 - ensure that notifications are being sent correctly. 74 - 75 - ## API Documentation 76 - 77 - See these endpoint pages: 78 - 79 - - [Create Webhook](/docs/api/operations/placestreamservercreatewebhook) 80 - - [Get Webhook](/docs/api/operations/placestreamservergetwebhook) 81 - - [List Webhooks](/docs/api/operations/placestreamserverlistwebhooks) 82 - - [Update Webhook](/docs/api/operations/placestreamserverupdatewebhook) 83 - - [Delete Webhook](/docs/api/operations/placestreamserverdeletewebhook)
-27
js/docs/src/content/docs/guides/start-streaming/embed.md
··· 1 - --- 2 - title: Embedding your livestream 3 - description: How to embed your livestream on your website, blog, etc. 4 - --- 5 - 6 - Streamplace provides an easy way to embed your livestream on any website or 7 - blog. 8 - 9 - You can access the embedded livestream page by putting `/embed` in the URL of 10 - your livestream. For example, if your livestream URL is 11 - `https://stream.place/iame.li`, the embed URL will be 12 - `https://stream.place/embed/iame.li`. 13 - 14 - You can use the following HTML snippet to embed your livestream: 15 - 16 - ```html 17 - <iframe 18 - src="https://stream.place/embed/your-handle" 19 - width="560" 20 - height="315" 21 - frameborder="0" 22 - allowfullscreen 23 - ></iframe> 24 - ``` 25 - 26 - Alternatively, you can use the share sheet located on your livestream page. 27 - Click the "Share" button, and you'll find the embed code ready to copy.
+7 -1
js/docs/src/content/docs/guides/start-streaming/obs-multistreaming.md
··· 1 1 --- 2 - title: OBS Multistreaming with Streamplace 2 + title: OBS Multistreaming to Streamplace 3 3 description: 4 4 Configure OBS for multistreaming to Streamplace and other platforms using the 5 5 obs-multi-rtmp plugin. 6 6 sidebar: 7 7 order: 20 8 8 --- 9 + 10 + :::note 11 + This guide is not about the multistreaming feature. Check 12 + [the multistreaming guide](/docs/features/multistreaming) out for more 13 + information. 14 + ::: 9 15 10 16 This guide explains how to configure Open Broadcaster Software (OBS) for 11 17 simultaneous streaming to Streamplace and other platforms using the
+8 -1
js/docs/src/content/docs/guides/start-streaming/obs.md
··· 66 66 67 67 - Video Encoder: x264/h264 (**must** be an x/h.264 encoder) 68 68 - Rate Control: `CBR` 69 - - Keyframe Interval: `1s` 69 + - Keyframe Interval: `1s` (or anything less than once every ~7s) 70 70 - This is _one keyframe per second_ 71 71 - In some situations (e.g. 'keyframe interval (**frames**)'), this should be 72 72 set to your FPS. 73 73 - x264 Options: `bframes=0` 74 74 - If available, there also may be a 'bframes' checkbox which should **NOT** be 75 75 checked 76 + 77 + :::caution 78 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 79 + ::: 76 80 77 81 ### 3. Announce your stream 78 82 ··· 90 94 - [OBS Multistreaming Guide](guides/obs-multistreaming) 91 95 92 96 2. [**Aitum Multistream Plugin**](https://aitum.tv/products/multi) 97 + 98 + Alternatively, you can 99 + [multistream through Streamplace itself.](/docs/features/multistreaming) 93 100 94 101 ## Best Practices 95 102
+73
js/docs/src/content/docs/guides/start-streaming/quick-start.md
··· 1 + --- 2 + title: Quick Start 3 + description: Get up and streaming on Streamplace quickly. 4 + sidebar: 5 + order: 1 6 + --- 7 + 8 + This guide gets you from zero to streaming. If you get stuck, check out the full [OBS setup guide](/docs/guides/start-streaming/obs). 9 + 10 + :::tip 11 + You will want to check out our [community guidelines](https://blog.stream.place/3mcqwibo4ks2w) first for guidance on what you can and cannot do on Streamplace. 12 + ::: 13 + 14 + ## So, what is Streamplace? 15 + 16 + Streamplace is a video streaming service built on top of the AT Protocol (Authenticated Transfer Protocol), the same protocol Bluesky is built on. 17 + 18 + ## Step 1: Create your account 19 + 20 + 1. Go to [stream.place](https://stream.place) 21 + 2. Click "Sign in" in the top right. 22 + 3. Use your Atmosphere credentials to log in (ex. your Bluesky handle) 23 + - You'll need to use your actual password here - we're using OAuth so you enter your password on your PDS. We do not receive your password at all. 24 + 4. You're done! Your stream profile is live at `stream.place/your-handle` 25 + 26 + ## Step 2: Get your stream key 27 + 28 + 1. Click **Live Dashboard** (or go to [stream.place/dashboard](https://stream.place/dashboard)) 29 + 2. Click **Stream from OBS** 30 + 3. Click **Generate Stream Key** 31 + 4. Your key is copied to clipboard automatically 32 + 33 + Keep this key private. It's like a password, but for your stream. 34 + 35 + ## Step 3: Configure OBS 36 + 37 + Open OBS and go to **Settings → Stream**: 38 + 39 + - **Service**: `Custom...` 40 + - **Server**: `rtmps://stream.place:1935/live` 41 + - **Stream Key**: Paste what you copied in Step 2 42 + 43 + Then go to **Settings → Output → Streaming**: 44 + 45 + - **Video Encoder**: `libx264` (or `NVIDIA NVENC H.264` if you have an NVIDIA GPU) 46 + - **Rate Control**: `CBR` 47 + - **Bitrate**: `6000` Kbps (adjust down if you drop frames) 48 + - **Keyframe Interval**: `1` 49 + - **x264 Options**: `bframes=0`. If there's a 'bframes' option, you'll want to have that at '0' or unchecked. 50 + 51 + :::caution 52 + These last two options are very important! Your viewers' experience may be choppy or otherwise subpar if you don't have them correct. 53 + ::: 54 + 55 + ## Step 4: Go live 56 + 57 + 1. In OBS, click **Start Streaming** 58 + 2. Go back to the Live Dashboard at stream.place 59 + 3. Fill in your stream title and optionally pick a thumbnail8 60 + 4. If needed, turn on content warnings. ("Metadata" tab in Stream Settings) 61 + 5. Click **Announce Livestream** 62 + 6. Your stream is now live and visible to the world! 63 + 64 + ## Next steps 65 + 66 + - **Customize your chat**: Change your name color in Settings > Account 67 + - **Stream to other platforms too**: Set your Twitch/YouTube URLs in Settings > Multistream Targets to push your stream there automatically. See the [Multistreaming guide](/docs/features/multistreaming) for more information 68 + - **Improve stream quality**: See the [OBS guide](/docs/guides/start-streaming/obs) for encoder settings and troubleshooting 69 + - **Join the Discord!**: If you need any help, or just want to chat, check out our discord at https://discord.stream.place. 70 + 71 + ### Credits 72 + 73 + A portion of this documentation was taken from [ndroo.tv](https://bsky.app/profile/ndroo.tv)'s excellent [guide on Streamplace](https://ndroo.tv/streamplace.html#2-configuring-your-account).
+2 -32
js/docs/src/content/docs/index.mdx
··· 2 2 title: Welcome to Streamplace! 3 3 description: Begin your development journey with the Streamplace documentation. 4 4 template: doc 5 - hero: 6 - tagline: Solve live video for your project with Streamplace. 7 - image: 8 - file: ../../assets/cube.png 9 - alt: Streamplace logo. A pink 3d box viewed from a top corner. 10 - actions: 11 - - text: Get Started 12 - link: /docs/guides/start-streaming/obs 13 - icon: right-arrow 14 - - text: Visit Streamplace 15 - link: / 16 - icon: external 17 - variant: minimal 18 5 --- 19 6 20 - import { Card, CardGrid } from "@astrojs/starlight/components"; 21 - 22 - ## Next Steps 7 + import HelpDesk from "../../components/HelpDesk.astro"; 23 8 24 - <CardGrid> 25 - <Card title="Read the Docs" icon="open-book"> 26 - Learn how to start streaming with 27 - [Streamplace](/docs/guides/start-streaming/obs). 28 - </Card> 29 - <Card title="Install Streamplace" icon="download"> 30 - [Run your own Streamplace 31 - node](/docs/guides/installing/installing-streamplace). 32 - </Card> 33 - <Card title="API Reference" icon="document"> 34 - Explore the [Lexicon API reference](/docs/lex-reference/place-stream-defs). 35 - </Card> 36 - <Card title="Developer Setup" icon="setting"> 37 - Set up your [development environment](/docs/guides/streamplace-dev-setup). 38 - </Card> 39 - </CardGrid> 9 + <HelpDesk />
+2 -1
js/docs/src/content/docs/lex-reference/branding/place-stream-branding-getblob.md
··· 28 28 - **Description:** Raw blob data with appropriate content-type 29 29 - **Schema:** 30 30 31 - _Schema not defined._ **Possible Errors:** 31 + _Schema not defined._ 32 + **Possible Errors:** 32 33 33 34 - `BrandingNotFound`: The requested branding asset does not exist 34 35
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-origin.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Record indicating a livestream is published and available for replication at a 17 - given address. By convention, the record key is streamer::server 16 + Record indicating a livestream is published and available for replication at a given address. By convention, the record key is streamer::server 18 17 19 18 **Record Key:** `any` 20 19
+1 -2
js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-syndication.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Record created by a Streamplace broadcaster to indicate that they will be 17 - replicating a livestream. NYI 16 + Record created by a Streamplace broadcaster to indicate that they will be replicating a livestream. NYI 18 17 19 18 **Record Key:** `tid` 20 19
+58 -2
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 20 20 | `uri` | `string` | ✅ | | Format: `at-uri` | 21 21 | `cid` | `string` | ✅ | | Format: `cid` | 22 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` | ✅ | | | 23 + | `record` | Union of:<br/>&nbsp;&nbsp;[`#messageRecordView`](#messagerecordview) | ✅ | | | 24 24 | `indexedAt` | `string` | ✅ | | Format: `datetime` | 25 25 | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | ❌ | | | 26 26 | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | ❌ | | | ··· 28 28 29 29 --- 30 30 31 + <a name="messagerecordview"></a> 32 + 33 + ### `messageRecordView` 34 + 35 + **Type:** `object` 36 + 37 + The content of a chat message. 38 + 39 + **Properties:** 40 + 41 + | Name | Type | Req'd | Description | Constraints | 42 + | ----------- | ------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------- | --------------------------------------- | 43 + | `text` | `string` | ✅ | The primary message content. May be an empty string, if there are embeds. | Max Length: 3000<br/>Max Graphemes: 300 | 44 + | `createdAt` | `string` | ✅ | Client-declared timestamp when this message was originally created. | Format: `datetime` | 45 + | `facets` | Array of [`place.stream.richtext.defs#facetView`](/lex-reference/place-stream-richtext-defs#facetview) | ❌ | Annotations of text (mentions, URLs, etc) | | 46 + | `streamer` | `string` | ✅ | The DID of the streamer whose chat this is. | Format: `did` | 47 + | `reply` | [`place.stream.chat.message#replyRef`](/lex-reference/place-stream-chat-message#replyref) | ❌ | | | 48 + 49 + --- 50 + 31 51 ## Lexicon Source 32 52 33 53 ```json ··· 52 72 "ref": "app.bsky.actor.defs#profileViewBasic" 53 73 }, 54 74 "record": { 55 - "type": "unknown" 75 + "type": "union", 76 + "refs": ["#messageRecordView"] 56 77 }, 57 78 "indexedAt": { 58 79 "type": "string", ··· 69 90 "deleted": { 70 91 "type": "boolean", 71 92 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 93 + } 94 + } 95 + }, 96 + "messageRecordView": { 97 + "type": "object", 98 + "description": "The content of a chat message.", 99 + "required": ["text", "createdAt", "streamer"], 100 + "properties": { 101 + "text": { 102 + "type": "string", 103 + "maxLength": 3000, 104 + "maxGraphemes": 300, 105 + "description": "The primary message content. May be an empty string, if there are embeds." 106 + }, 107 + "createdAt": { 108 + "type": "string", 109 + "format": "datetime", 110 + "description": "Client-declared timestamp when this message was originally created." 111 + }, 112 + "facets": { 113 + "type": "array", 114 + "description": "Annotations of text (mentions, URLs, etc)", 115 + "items": { 116 + "type": "ref", 117 + "ref": "place.stream.richtext.defs#facetView" 118 + } 119 + }, 120 + "streamer": { 121 + "type": "string", 122 + "format": "did", 123 + "description": "The DID of the streamer whose chat this is." 124 + }, 125 + "reply": { 126 + "type": "ref", 127 + "ref": "place.stream.chat.message#replyRef" 72 128 } 73 129 } 74 130 }
+2 -1
js/docs/src/content/docs/lex-reference/live/place-stream-live-getprofilecard.md
··· 26 26 - **Encoding:** `*/*` 27 27 - **Schema:** 28 28 29 - _Schema not defined._ **Possible Errors:** 29 + _Schema not defined._ 30 + **Possible Errors:** 30 31 31 32 - `RepoNotFound` 32 33
+1 -2
js/docs/src/content/docs/lex-reference/live/place-stream-live-searchactorstypeahead.md
··· 13 13 14 14 **Type:** `query` 15 15 16 - Find actor suggestions for a prefix search term. Expected use is for 17 - auto-completion during text field entry. 16 + Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. 18 17 19 18 **Parameters:** 20 19
+1 -2
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
··· 13 13 14 14 **Type:** `record` 15 15 16 - Default metadata record for livestream including content warnings, rights, and 17 - distribution policy 16 + Default metadata record for livestream including content warnings, rights, and distribution policy 18 17 19 18 **Record Key:** `literal:self` 20 19
+8 -19
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
··· 33 33 34 34 **Type:** `token` 35 35 36 - All rights reserved to the creator — others cannot use, modify, or share without 37 - explicit authorization. 36 + All rights reserved to the creator — others cannot use, modify, or share without explicit authorization. 38 37 39 38 --- 40 39 ··· 44 43 45 44 **Type:** `token` 46 45 47 - Public domain dedication. You waive all copyright and related rights where 48 - possible. Others may copy, modify, distribute, or perform your work for any 49 - purpose without attribution. 46 + Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution. 50 47 51 48 --- 52 49 ··· 56 53 57 54 **Type:** `token` 58 55 59 - Attribution required. Others may copy, distribute, remix, and build upon your 60 - work, even commercially, if they credit you. 56 + Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you. 61 57 62 58 --- 63 59 ··· 67 63 68 64 **Type:** `token` 69 65 70 - Attribution + share-alike. Others may adapt and build upon your work, even 71 - commercially, if they credit you and license their new creations under identical 72 - terms. 66 + Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms. 73 67 74 68 --- 75 69 ··· 79 73 80 74 **Type:** `token` 81 75 82 - Attribution + non-commercial. Others may adapt and build upon your work for 83 - non-commercial purposes only, and must credit you. 76 + Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you. 84 77 85 78 --- 86 79 ··· 90 83 91 84 **Type:** `token` 92 85 93 - Attribution + non-commercial + share-alike. Others may adapt and build upon your 94 - work for non-commercial purposes only, must credit you, and must license their 95 - new creations under identical terms. 86 + Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms. 96 87 97 88 --- 98 89 ··· 102 93 103 94 **Type:** `token` 104 95 105 - Attribution + no derivatives. Others may reuse your work, even commercially, but 106 - it must remain unchanged and you must be credited. 96 + Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited. 107 97 108 98 --- 109 99 ··· 113 103 114 104 **Type:** `token` 115 105 116 - Attribution + non-commercial + no derivatives. Others may download and share 117 - your work with credit, but cannot change it or use it commercially. 106 + Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially. 118 107 119 108 --- 120 109
+8 -18
js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
··· 29 29 30 30 **Type:** `token` 31 31 32 - The content could be perceived as offensive due to the discussion or display of 33 - death. 32 + The content could be perceived as offensive due to the discussion or display of death. 34 33 35 34 --- 36 35 ··· 40 39 41 40 **Type:** `token` 42 41 43 - The content contains a portrayal of the use or abuse of mind altering 44 - substances. 42 + The content contains a portrayal of the use or abuse of mind altering substances. 45 43 46 44 --- 47 45 ··· 51 49 52 50 **Type:** `token` 53 51 54 - The content contains violent actions of a fantasy nature, involving human or 55 - non-human characters in situations easily distinguishable from real life. 52 + The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life. 56 53 57 54 --- 58 55 ··· 62 59 63 60 **Type:** `token` 64 61 65 - The content contains flashing lights that could be harmful to viewers with 66 - seizure disorders such as photosensitive epilepsy. 62 + The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy. 67 63 68 64 --- 69 65 ··· 93 89 94 90 **Type:** `token` 95 91 96 - The content contains information that can be used to identify a particular 97 - individual, such as a name, phone number, email address, physical address, or IP 98 - address. 92 + The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address. 99 93 100 94 --- 101 95 ··· 105 99 106 100 **Type:** `token` 107 101 108 - The content could be perceived as offensive due to the discussion or display of 109 - sexuality. 102 + The content could be perceived as offensive due to the discussion or display of sexuality. 110 103 111 104 --- 112 105 ··· 116 109 117 110 **Type:** `token` 118 111 119 - The content could be perceived as distressing due to the discussion or display 120 - of suffering or triggering topics, including suicide, eating disorders or self 121 - harm. 112 + The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm. 122 113 123 114 --- 124 115 ··· 128 119 129 120 **Type:** `token` 130 121 131 - The content could be perceived as offensive due to the discussion or display of 132 - violence. 122 + The content could be perceived as offensive due to the discussion or display of violence. 133 123 134 124 --- 135 125
+3 -6
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createblock.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates 17 - an app.bsky.graph.block record in the streamer's repository. 16 + Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository. 18 17 19 18 **Parameters:** _(None defined)_ 20 19 ··· 46 45 **Possible Errors:** 47 46 48 47 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to create blocks for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 48 + - `Forbidden`: The caller does not have permission to create blocks for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 53 50 54 51 --- 55 52
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-creategate.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Create a gate (hide message) on behalf of a streamer. Requires 'hide' 17 - permission. Creates a place.stream.chat.gate record in the streamer's 18 - repository. 16 + Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 46 44 **Possible Errors:** 47 45 48 46 - `Unauthorized`: The request lacks valid authentication credentials. 49 - - `Forbidden`: The caller does not have permission to hide messages for this 50 - streamer. 51 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 - invalid. 47 + - `Forbidden`: The caller does not have permission to hide messages for this streamer. 48 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 53 49 54 50 --- 55 51
+5 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deleteblock.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. 17 - Deletes an app.bsky.graph.block record from the streamer's repository. 16 + Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository. 18 17 19 18 **Parameters:** _(None defined)_ 20 19 ··· 37 36 38 37 **Schema Type:** `object` 39 38 40 - _(No properties defined)_ **Possible Errors:** 39 + _(No properties defined)_ 40 + **Possible Errors:** 41 41 42 42 - `Unauthorized`: The request lacks valid authentication credentials. 43 - - `Forbidden`: The caller does not have permission to delete blocks for this 44 - streamer. 45 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 46 - invalid. 43 + - `Forbidden`: The caller does not have permission to delete blocks for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 47 45 48 46 --- 49 47
+5 -8
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletegate.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' 17 - permission. Deletes a place.stream.chat.gate record from the streamer's 18 - repository. 16 + Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 38 36 39 37 **Schema Type:** `object` 40 38 41 - _(No properties defined)_ **Possible Errors:** 39 + _(No properties defined)_ 40 + **Possible Errors:** 42 41 43 42 - `Unauthorized`: The request lacks valid authentication credentials. 44 - - `Forbidden`: The caller does not have permission to unhide messages for this 45 - streamer. 46 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 47 - invalid. 43 + - `Forbidden`: The caller does not have permission to unhide messages for this streamer. 44 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 48 45 49 46 --- 50 47
+3 -7
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-updatelivestream.md
··· 13 13 14 14 **Type:** `procedure` 15 15 16 - Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 - permission. Updates a place.stream.livestream record in the streamer's 18 - repository. 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository. 19 17 20 18 **Parameters:** _(None defined)_ 21 19 ··· 47 45 **Possible Errors:** 48 46 49 47 - `Unauthorized`: The request lacks valid authentication credentials. 50 - - `Forbidden`: The caller does not have permission to update livestream metadata 51 - for this streamer. 52 - - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 - invalid. 48 + - `Forbidden`: The caller does not have permission to update livestream metadata for this streamer. 49 + - `SessionNotFound`: The streamer's OAuth session could not be found or is invalid. 54 50 - `RecordNotFound`: The specified livestream record does not exist. 55 51 56 52 ---
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-createtarget.md
··· 33 33 - **Encoding:** `application/json` 34 34 - **Schema:** 35 35 36 - **Schema Type:** 37 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 36 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 38 37 39 38 **Possible Errors:** 40 39
+1 -2
js/docs/src/content/docs/lex-reference/multistream/place-stream-multistream-puttarget.md
··· 34 34 - **Encoding:** `application/json` 35 35 - **Schema:** 36 36 37 - **Schema Type:** 38 - [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 37 + **Schema Type:** [`place.stream.multistream.defs#targetView`](/lex-reference/place-stream-multistream-defs#targetview) 39 38 40 39 **Possible Errors:** 41 40
+138
js/docs/src/content/docs/lex-reference/richtext/place-stream-richtext-defs.md
··· 1 + --- 2 + title: place.stream.richtext.defs 3 + description: Reference for the place.stream.richtext.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="facetview"></a> 11 + 12 + ### `facetView` 13 + 14 + **Type:** `object` 15 + 16 + Annotation of a sub-string within rich text. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | 22 + | `index` | [`app.bsky.richtext.facet#byteSlice`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#byteSlice) | ✅ | | | 23 + | `features` | Array of Union of:<br/>&nbsp;&nbsp;[`app.bsky.richtext.facet#mention`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#mention)<br/>&nbsp;&nbsp;[`app.bsky.richtext.facet#link`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/richtext/facet.json#link)<br/>&nbsp;&nbsp;[`#censor`](#censor) | ✅ | | | 24 + 25 + --- 26 + 27 + <a name="censor"></a> 28 + 29 + ### `censor` 30 + 31 + **Type:** `object` 32 + 33 + Indicates that the text in the given index has been censored. 34 + 35 + **Properties:** 36 + 37 + | Name | Type | Req'd | Description | Constraints | 38 + | ------------ | ----------------- | ----- | ------------------------------ | ----------- | 39 + | `reason` | `string` | ❌ | | | 40 + | `categories` | Array of `string` | ❌ | Categories of censored content | | 41 + 42 + --- 43 + 44 + <a name="discriminatory"></a> 45 + 46 + ### `discriminatory` 47 + 48 + **Type:** `token` 49 + 50 + Indicates that the text has been censored due to discriminatory content. 51 + 52 + --- 53 + 54 + <a name="sexuallyexplicit"></a> 55 + 56 + ### `sexually_explicit` 57 + 58 + **Type:** `token` 59 + 60 + Indicates that the text has been censored due to sexually explicit content. 61 + 62 + --- 63 + 64 + <a name="profanity"></a> 65 + 66 + ### `profanity` 67 + 68 + **Type:** `token` 69 + 70 + Indicates that the text has been censored due to profanity. 71 + 72 + --- 73 + 74 + ## Lexicon Source 75 + 76 + ```json 77 + { 78 + "lexicon": 1, 79 + "id": "place.stream.richtext.defs", 80 + "defs": { 81 + "facetView": { 82 + "type": "object", 83 + "description": "Annotation of a sub-string within rich text.", 84 + "required": ["index", "features"], 85 + "properties": { 86 + "index": { 87 + "type": "ref", 88 + "ref": "app.bsky.richtext.facet#byteSlice" 89 + }, 90 + "features": { 91 + "type": "array", 92 + "items": { 93 + "type": "union", 94 + "refs": [ 95 + "app.bsky.richtext.facet#mention", 96 + "app.bsky.richtext.facet#link", 97 + "#censor" 98 + ] 99 + } 100 + } 101 + } 102 + }, 103 + "censor": { 104 + "type": "object", 105 + "description": "Indicates that the text in the given index has been censored.", 106 + "properties": { 107 + "reason": { 108 + "type": "string" 109 + }, 110 + "categories": { 111 + "type": "array", 112 + "items": { 113 + "type": "string", 114 + "knownValues": [ 115 + "place.stream.richtext.defs#discriminatory", 116 + "place.stream.richtext.defs#sexually_explicit", 117 + "place.stream.richtext.defs#profanity" 118 + ] 119 + }, 120 + "description": "Categories of censored content" 121 + } 122 + } 123 + }, 124 + "discriminatory": { 125 + "type": "token", 126 + "description": "Indicates that the text has been censored due to discriminatory content." 127 + }, 128 + "sexually_explicit": { 129 + "type": "token", 130 + "description": "Indicates that the text has been censored due to sexually explicit content." 131 + }, 132 + "profanity": { 133 + "type": "token", 134 + "description": "Indicates that the text has been censored due to profanity." 135 + } 136 + } 137 + } 138 + ```
+9
js/docs/src/content/docs/reference.mdx
··· 1 + --- 2 + title: API Reference 3 + description: Our XRPC and OpenAPI Reference documentation 4 + template: doc 5 + --- 6 + 7 + import { Card, CardGrid } from "@astrojs/starlight/components"; 8 + 9 + Here contains our XRPC and OpenAPI Reference documentation.
+1 -1
lerna.json
··· 1 1 { 2 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.9.6", 3 + "version": "0.9.9", 4 4 "npmClient": "pnpm" 5 5 }
+39 -1
lexicons/place/stream/chat/defs.json
··· 12 12 "type": "ref", 13 13 "ref": "app.bsky.actor.defs#profileViewBasic" 14 14 }, 15 - "record": { "type": "unknown" }, 15 + "record": { 16 + "type": "union", 17 + "refs": ["#messageRecordView"] 18 + }, 16 19 "indexedAt": { "type": "string", "format": "datetime" }, 17 20 "chatProfile": { 18 21 "type": "ref", ··· 25 28 "deleted": { 26 29 "type": "boolean", 27 30 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 31 + } 32 + } 33 + }, 34 + "messageRecordView": { 35 + "type": "object", 36 + "description": "The content of a chat message.", 37 + "required": ["text", "createdAt", "streamer"], 38 + "properties": { 39 + "text": { 40 + "type": "string", 41 + "maxLength": 3000, 42 + "maxGraphemes": 300, 43 + "description": "The primary message content. May be an empty string, if there are embeds." 44 + }, 45 + "createdAt": { 46 + "type": "string", 47 + "format": "datetime", 48 + "description": "Client-declared timestamp when this message was originally created." 49 + }, 50 + "facets": { 51 + "type": "array", 52 + "description": "Annotations of text (mentions, URLs, etc)", 53 + "items": { 54 + "type": "ref", 55 + "ref": "place.stream.richtext.defs#facetView" 56 + } 57 + }, 58 + "streamer": { 59 + "type": "string", 60 + "format": "did", 61 + "description": "The DID of the streamer whose chat this is." 62 + }, 63 + "reply": { 64 + "type": "ref", 65 + "ref": "place.stream.chat.message#replyRef" 28 66 } 29 67 } 30 68 }
+56
lexicons/place/stream/richtext/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.richtext.defs", 4 + "defs": { 5 + "facetView": { 6 + "type": "object", 7 + "description": "Annotation of a sub-string within rich text.", 8 + "required": ["index", "features"], 9 + "properties": { 10 + "index": { "type": "ref", "ref": "app.bsky.richtext.facet#byteSlice" }, 11 + "features": { 12 + "type": "array", 13 + "items": { 14 + "type": "union", 15 + "refs": [ 16 + "app.bsky.richtext.facet#mention", 17 + "app.bsky.richtext.facet#link", 18 + "#censor" 19 + ] 20 + } 21 + } 22 + } 23 + }, 24 + "censor": { 25 + "type": "object", 26 + "description": "Indicates that the text in the given index has been censored.", 27 + "properties": { 28 + "reason": { "type": "string" }, 29 + "categories": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "knownValues": [ 34 + "place.stream.richtext.defs#discriminatory", 35 + "place.stream.richtext.defs#sexually_explicit", 36 + "place.stream.richtext.defs#profanity" 37 + ] 38 + }, 39 + "description": "Categories of censored content" 40 + } 41 + } 42 + }, 43 + "discriminatory": { 44 + "type": "token", 45 + "description": "Indicates that the text has been censored due to discriminatory content." 46 + }, 47 + "sexually_explicit": { 48 + "type": "token", 49 + "description": "Indicates that the text has been censored due to sexually explicit content." 50 + }, 51 + "profanity": { 52 + "type": "token", 53 + "description": "Indicates that the text has been censored due to profanity." 54 + } 55 + } 56 + }
+6 -58
pkg/api/api.go
··· 35 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
+3 -3
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 ··· 418 418 errors.WriteHTTPInternalServerError(w, "unable to get chat posts", err) 419 419 return 420 420 } 421 - spmsg, err := msg.ToStreamplaceMessageView() 421 + spmsg, err := msg.ToStreamplaceMessageView(nil) 422 422 if err != nil { 423 423 errors.WriteHTTPInternalServerError(w, "unable to convert chat message to streamplace message view", err) 424 424 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
+1 -1
pkg/api/websocket.go
··· 181 181 }() 182 182 183 183 go func() { 184 - seg, err := a.Model.LatestSegmentForUser(repoDID) 184 + seg, err := a.LocalDB.LatestSegmentForUser(repoDID) 185 185 if err != nil { 186 186 log.Error(ctx, "could not get replies", "error", err) 187 187 return
+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 }
+9 -9
pkg/atproto/chat_message_test.go
··· 112 112 }) 113 113 // Reverse the messages slice to match expected order (most recent first) 114 114 slices.SortFunc(messages, func(a, b *streamplace.ChatDefs_MessageView) int { 115 - aTime := a.Record.Val.(*streamplace.ChatMessage).CreatedAt 116 - bTime := b.Record.Val.(*streamplace.ChatMessage).CreatedAt 115 + aTime := a.Record.ChatDefs_MessageRecordView.CreatedAt 116 + bTime := b.Record.ChatDefs_MessageRecordView.CreatedAt 117 117 if aTime < bTime { 118 118 return -1 119 119 } else if aTime > bTime { ··· 122 122 return 0 123 123 }) 124 124 slices.SortFunc(busMessages, func(a, b bus.Message) int { 125 - aTime := a.(*streamplace.ChatDefs_MessageView).Record.Val.(*streamplace.ChatMessage).CreatedAt 126 - bTime := b.(*streamplace.ChatDefs_MessageView).Record.Val.(*streamplace.ChatMessage).CreatedAt 125 + aTime := a.(*streamplace.ChatDefs_MessageView).Record.ChatDefs_MessageRecordView.CreatedAt 126 + bTime := b.(*streamplace.ChatDefs_MessageView).Record.ChatDefs_MessageRecordView.CreatedAt 127 127 if aTime < bTime { 128 128 return -1 129 129 } else if aTime > bTime { ··· 131 131 } 132 132 return 0 133 133 }) 134 - require.Equal(t, msg.Text, messages[0].Record.Val.(*streamplace.ChatMessage).Text) 135 - require.Equal(t, msg2.Text, messages[1].Record.Val.(*streamplace.ChatMessage).Text) 134 + require.Equal(t, msg.Text, messages[0].Record.ChatDefs_MessageRecordView.Text) 135 + require.Equal(t, msg2.Text, messages[1].Record.ChatDefs_MessageRecordView.Text) 136 136 busMessage1 := busMessages[0].(*streamplace.ChatDefs_MessageView) 137 137 busMessage2 := busMessages[1].(*streamplace.ChatDefs_MessageView) 138 - require.Equal(t, msg.Text, busMessage1.Record.Val.(*streamplace.ChatMessage).Text) 139 - require.Equal(t, msg2.Text, busMessage2.Record.Val.(*streamplace.ChatMessage).Text) 138 + require.Equal(t, msg.Text, busMessage1.Record.ChatDefs_MessageRecordView.Text) 139 + require.Equal(t, msg2.Text, busMessage2.Record.ChatDefs_MessageRecordView.Text) 140 140 141 141 rkey := strings.TrimPrefix(rec1.Uri, fmt.Sprintf("at://%s/place.stream.chat.message/", user.DID)) 142 142 ··· 162 162 return nil 163 163 }) 164 164 require.NoError(t, err) 165 - require.Equal(t, msg2.Text, messages[0].Record.Val.(*streamplace.ChatMessage).Text) 165 + require.Equal(t, msg2.Text, messages[0].Record.ChatDefs_MessageRecordView.Text) 166 166 busMessage3 := busMessages[2].(*streamplace.ChatDefs_MessageView) 167 167 require.Equal(t, true, *busMessage3.Deleted) 168 168
+1 -1
pkg/atproto/firehose.go
··· 295 295 log.Error(ctx, "failed to delete chat message", "err", err) 296 296 continue 297 297 } 298 - mv, err := msg.ToStreamplaceMessageView() 298 + mv, err := msg.ToStreamplaceMessageView(nil) 299 299 if err != nil { 300 300 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 301 301 continue
+1 -1
pkg/atproto/labeler_firehose.go
··· 182 182 log.Error(ctx, "failed to get chat message for label", "err", err) 183 183 continue 184 184 } 185 - chatView, err := msg.ToStreamplaceMessageView() 185 + chatView, err := msg.ToStreamplaceMessageView(nil) 186 186 if err != nil { 187 187 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 188 188 continue
+2 -2
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() ··· 145 145 log.Error(ctx, "failed to retrieve just-saved chat message", "err", err) 146 146 return nil 147 147 } 148 - scm, err := mcm.ToStreamplaceMessageView() 148 + scm, err := mcm.ToStreamplaceMessageView(nil) 149 149 if err != nil { 150 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 151 return nil
+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.")
+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 {
+6 -4
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 ··· 178 180 aqt := aqtime.FromTime(notif.Segment.StartTime) 179 181 ctx = log.WithLogValues(ctx, "segID", notif.Segment.ID, "repoDID", notif.Segment.RepoDID, "timestamp", aqt.FileSafeString()) 180 182 notif.Segment.MediaData.Size = len(notif.Data) 181 - err := ss.mod.CreateSegment(notif.Segment) 183 + err := ss.localDB.CreateSegment(notif.Segment) 182 184 if err != nil { 183 185 return fmt.Errorf("could not add segment to database: %w", err) 184 186 } ··· 292 294 return nil 293 295 } 294 296 defer lock.Unlock() 295 - oldThumb, err := ss.mod.LatestThumbnailForUser(not.Segment.RepoDID) 297 + oldThumb, err := ss.localDB.LatestThumbnailForUser(not.Segment.RepoDID) 296 298 if err != nil { 297 299 return err 298 300 } ··· 311 313 if err != nil { 312 314 return err 313 315 } 314 - thumb := &model.Thumbnail{ 316 + thumb := &localdb.Thumbnail{ 315 317 Format: "jpeg", 316 318 SegmentID: not.Segment.ID, 317 319 } 318 - err = ss.mod.CreateThumbnail(thumb) 320 + err = ss.localDB.CreateThumbnail(thumb) 319 321 if err != nil { 320 322 return err 321 323 }
+1 -4
pkg/integrations/discord/send-chat.go
··· 17 17 18 18 func SendChat(ctx context.Context, w *discordtypes.Webhook, did string, scm *streamplace.ChatDefs_MessageView) error { 19 19 20 - msg, ok := scm.Record.Val.(*streamplace.ChatMessage) 21 - if !ok { 22 - return fmt.Errorf("failed to cast chat message to streamplace chat message") 23 - } 20 + msg := scm.Record.ChatDefs_MessageRecordView 24 21 25 22 avatarURL, err := GetAvatarURL(ctx, did) 26 23 if err != nil {
+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
+7 -8
pkg/integrations/webhook/manager.go
··· 14 14 // SendChatWebhook sends chat message to a specific webhook 15 15 func SendChatWebhook(ctx context.Context, webhook *streamplace.ServerDefs_Webhook, authorDID string, scm *streamplace.ChatDefs_MessageView) error { 16 16 // Check if message should be muted 17 - if msg, ok := scm.Record.Val.(*streamplace.ChatMessage); ok { 18 - if len(webhook.MuteWords) > 0 { 19 - messageText := strings.ToLower(msg.Text) 20 - for _, muteWord := range webhook.MuteWords { 21 - if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 - // Message contains a mute word, skip forwarding 23 - return nil 24 - } 17 + msg := scm.Record.ChatDefs_MessageRecordView 18 + if len(webhook.MuteWords) > 0 { 19 + messageText := strings.ToLower(msg.Text) 20 + for _, muteWord := range webhook.MuteWords { 21 + if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 + // Message contains a mute word, skip forwarding 23 + return nil 25 24 } 26 25 } 27 26 }
+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)
+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)
+64 -5
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" 15 + "stream.place/streamplace/pkg/stars" 13 16 "stream.place/streamplace/pkg/streamplace" 14 17 ) 15 18 ··· 36 39 return int(h.Sum32()) 37 40 } 38 41 39 - func (m *ChatMessage) ToStreamplaceMessageView() (*streamplace.ChatDefs_MessageView, error) { 42 + func (m *ChatMessage) ToStreamplaceMessageView(starrer *stars.Starrer) (*streamplace.ChatDefs_MessageView, error) { 40 43 rec, err := lexutil.CborDecodeValue(*m.ChatMessage) 41 44 if err != nil { 42 45 return nil, fmt.Errorf("error decoding feed post: %w", err) 43 46 } 47 + 48 + msg, ok := rec.(*streamplace.ChatMessage) 49 + if !ok { 50 + return nil, fmt.Errorf("expected *streamplace.ChatMessage, got %T", rec) 51 + } 52 + 53 + // Truncate message text if needed 54 + text := msg.Text 55 + graphemeCount := uniseg.GraphemeClusterCount(text) 56 + if graphemeCount > 300 { 57 + gr := uniseg.NewGraphemes(text) 58 + var result strings.Builder 59 + for count := 0; count < 300 && gr.Next(); count++ { 60 + result.WriteString(gr.Str()) 61 + } 62 + text = result.String() 63 + } 64 + 65 + // Convert facets to facet views if needed 66 + var facetViews []*streamplace.RichtextDefs_FacetView 67 + for _, facet := range msg.Facets { 68 + var features []*streamplace.RichtextDefs_FacetView_Features_Elem 69 + for _, feature := range facet.Features { 70 + viewFeature := &streamplace.RichtextDefs_FacetView_Features_Elem{ 71 + RichtextFacet_Mention: feature.RichtextFacet_Mention, 72 + RichtextFacet_Link: feature.RichtextFacet_Link, 73 + } 74 + features = append(features, viewFeature) 75 + } 76 + facetViews = append(facetViews, &streamplace.RichtextDefs_FacetView{ 77 + Index: facet.Index, 78 + Features: features, 79 + }) 80 + } 81 + 82 + // Create the message record view 83 + recordView := &streamplace.ChatDefs_MessageRecordView{ 84 + LexiconTypeID: "place.stream.chat.defs#messageRecordView", 85 + Text: text, 86 + CreatedAt: msg.CreatedAt, 87 + Streamer: msg.Streamer, 88 + Facets: facetViews, 89 + Reply: msg.Reply, 90 + } 91 + 44 92 message := &streamplace.ChatDefs_MessageView{ 45 93 LexiconTypeID: "place.stream.chat.defs#messageView", 46 94 } ··· 52 100 if m.Repo != nil { 53 101 message.Author.Handle = m.Repo.Handle 54 102 } 55 - message.Record = &lexutil.LexiconTypeDecoder{Val: rec} 103 + message.Record = &streamplace.ChatDefs_MessageView_Record{ 104 + ChatDefs_MessageRecordView: recordView, 105 + } 56 106 message.IndexedAt = m.IndexedAt.UTC().Format(time.RFC3339Nano) 57 107 if m.ChatProfile != nil { 58 108 scp, err := m.ChatProfile.ToStreamplaceChatProfile() ··· 69 119 70 120 } 71 121 if m.ReplyTo != nil { 72 - replyTo, err := m.ReplyTo.ToStreamplaceMessageView() 122 + replyTo, err := m.ReplyTo.ToStreamplaceMessageView(starrer) 73 123 if err != nil { 74 124 return nil, fmt.Errorf("error converting reply to to streamplace message view: %w", err) 75 125 } ··· 77 127 ChatDefs_MessageView: replyTo, 78 128 } 79 129 } 130 + 131 + if starrer != nil { 132 + censoredMsg, err := starrer.CensorMessageView(message) 133 + if err != nil { 134 + return nil, fmt.Errorf("error censoring message: %w", err) 135 + } 136 + return censoredMsg, nil 137 + } 138 + 80 139 return message, nil 81 140 } 82 141 ··· 143 202 return nil, fmt.Errorf("error retrieving replies: %w", err) 144 203 } 145 204 spmessages := []*streamplace.ChatDefs_MessageView{} 146 - for _, m := range dbmessages { 147 - spmessage, err := m.ToStreamplaceMessageView() 205 + for _, msg := range dbmessages { 206 + spmessage, err := msg.ToStreamplaceMessageView(m.starrer) 148 207 if err != nil { 149 208 return nil, fmt.Errorf("error converting feed post to bsky post view: %w", err) 150 209 }
+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
+10 -18
pkg/model/model.go
··· 15 15 "gorm.io/plugin/prometheus" 16 16 "stream.place/streamplace/pkg/config" 17 17 "stream.place/streamplace/pkg/log" 18 + "stream.place/streamplace/pkg/stars" 18 19 "stream.place/streamplace/pkg/streamplace" 19 20 ) 20 21 21 22 type DBModel struct { 22 - DB *gorm.DB 23 + DB *gorm.DB 24 + starrer *stars.Starrer 23 25 } 24 26 25 27 type Model interface { ··· 27 29 ListPlayerEvents(playerID string) ([]PlayerEvent, error) 28 30 PlayerReport(playerID string) (map[string]any, error) 29 31 ClearPlayerEvents() error 30 - 31 - CreateSegment(segment *Segment) error 32 - MostRecentSegments() ([]Segment, error) 33 - LatestSegmentForUser(user string) (*Segment, error) 34 - LatestSegmentsForUser(user string, limit int, before *time.Time, after *time.Time) ([]Segment, error) 35 - FilterLiveRepoDIDs(repoDIDs []string) ([]string, error) 36 - CreateThumbnail(thumb *Thumbnail) error 37 - LatestThumbnailForUser(user string) (*Thumbnail, error) 38 - GetSegment(id string) (*Segment, error) 39 - GetExpiredSegments(ctx context.Context) ([]Segment, error) 40 - DeleteSegment(ctx context.Context, id string) error 41 - StartSegmentCleaner(ctx context.Context) error 42 - SegmentCleaner(ctx context.Context) error 43 32 44 33 GetIdentity(id string) (*Identity, error) 45 34 UpdateIdentity(ident *Identity) error ··· 72 61 CreateLivestream(ctx context.Context, ls *Livestream) error 73 62 GetLatestLivestreamForRepo(repoDID string) (*Livestream, error) 74 63 GetLivestreamByPostURI(postURI string) (*Livestream, error) 75 - GetLatestLivestreams(limit int, before *time.Time) ([]Livestream, error) 64 + GetLatestLivestreams(limit int, before *time.Time, dids []string) ([]Livestream, error) 76 65 77 66 CreateTeleport(ctx context.Context, tp *Teleport) error 78 67 GetLatestTeleportForRepo(repoDID string) (*Teleport, error) ··· 171 160 return nil, fmt.Errorf("error using prometheus plugin: %w", err) 172 161 } 173 162 163 + starrer, err := stars.NewDefaultStarrer() 164 + if err != nil { 165 + return nil, fmt.Errorf("error creating default starrer: %w", err) 166 + } 167 + 174 168 sqlDB, err := db.DB() 175 169 if err != nil { 176 170 return nil, fmt.Errorf("error getting database: %w", err) ··· 178 172 sqlDB.SetMaxOpenConns(1) 179 173 for _, model := range []any{ 180 174 PlayerEvent{}, 181 - Segment{}, 182 - Thumbnail{}, 183 175 Identity{}, 184 176 Repo{}, 185 177 SigningKey{}, ··· 204 196 return nil, err 205 197 } 206 198 } 207 - return &DBModel{DB: db}, nil 199 + return &DBModel{DB: db, starrer: starrer}, nil 208 200 }
-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 }
+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())
+156
pkg/stars/stars.go
··· 1 + package stars 2 + 3 + // censors a message (with stars) based on regex patterns 4 + import ( 5 + "encoding/json" 6 + "os" 7 + "regexp" 8 + "strings" 9 + 10 + appbsky "github.com/bluesky-social/indigo/api/bsky" 11 + "stream.place/streamplace/pkg/streamplace" 12 + ) 13 + 14 + // PatternDef defines a pattern with its associated categories 15 + type PatternDef struct { 16 + Pattern string 17 + Categories []string 18 + } 19 + 20 + // Default patterns for common profanity and slurs (case-insensitive) 21 + var DefaultPatterns = []PatternDef{ 22 + {`(?i)\b[cCϲсᴄⅽcçćčĉċ¢©🅒🅲𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ⓒⒸᶜ\(\[\{<ⲥꮯ€🇨][uUυսuùúûüũūŭůűųưᴜᵘᵤ🅤🆄𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎𝘂𝘶𝙪𝚞ⓤⓊʋꞟꭎꭒ𑣘ט𑜆🇺𝖀vμ][nNոռnñńņňʼnṅṇṉṋɴⁿ🅝🅽𝐧𝑛𝒏𝓃𝓷𝔫𝕟𝖓𝗇𝗻𝘯𝙣𝚗ⓝⓃ🇳ηŋℕ𝕹][tTтtţťŧṫṭṯṱᴛᵗ7\+🅣🆃𝐭𝑡𝒕𝓉𝓽𝔱𝕥𝖙𝗍𝘁𝘵𝙩𝚝ⓣⓉ†✝]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 23 + {`(?i)\b(n|\|\\||🇳|ո|ռ|🅝|𝕹)+(i|1|!|\||l|🇮|ℹ️|ı|ɩ|ɪ|ӏ|Ꭵ|ꙇ|ꭵ|ǀ|Ι|І|Ӏ|׀|ו|ן|١|۱|ا|Ⲓ|ⵏ|ꓲ|𐊊|𐌉|𐌠|𖼨|ﺍ|ﺎ|│|🅘|𝕴)+(g|9|🇬|ƍ|ɡ|ᶃ|🅖|𝕲)`, []string{"place.stream.richtext.defs#discriminatory"}}, 24 + {`(?i)\b(f|ƒ|£|🇫|ẝ|ꞙ|ꬵ|🅕|𝖿|𝕱)+(a|4|@|∆|/-\\|/_\\|Д|🇦|🅰️|ɑ|а|🅐|𝖺|𝕬)+(g|9|🇬|ƍ|ɡ|ᶃ|🅖|𝕲)+`, []string{"place.stream.richtext.defs#discriminatory"}}, 25 + {`(?i)\b[rRᎡꓣ𝐑𝐫𝑹𝒓ℛℜℝ𝓇𝓡𝓻𝔯𝕣𝕽𝖗𝖱𝗋𝗥𝗿𝘙𝘳𝙍𝙧𝚁𝚛ⓇⓡʀᴿʳŕŗřȑȓɍɹɻɼɽɾṙṛṝṟгГ®🅡🆁🄡][eEЕеᎬꓰ𝐄𝐞𝑬𝒆ℰℯ𝓔𝓮𝔈𝔢𝔼𝕖𝕰𝖊𝖤𝖾𝗘𝗲𝘌𝘦𝙀𝙚𝙴𝚎ⒺⓔᴇᴱᵉₑɛεΕёЁèéêëēĕėęěȅȇȩɇѐєҽ3€🅔🅴🄴℮ǝƎ∃][tTТтᎢꓔ𝐓𝐭𝑻𝒕𝒯𝓉𝓣𝓽𝔗𝔱𝕋𝕥𝕿𝖙𝖳𝗍𝗧𝘁𝘛𝘵𝙏𝙩𝚃𝚝Ⓣⓣᴛᵀᵗţťŧțȶṫṭṯṱ7\+†✝🅣🆃🄣][aAАаᎪꓮꭺ𐊠𝐀𝐚𝑨𝒂𝒜𝒶𝓐𝓪𝔄𝔞𝔸𝕒𝕬𝖆𝖠𝖺𝗔𝗮𝘈𝘢𝘼𝙖𝙰𝚊𝚨ⒶⓐᴀᴬᵃᵅₐɐɑαΑäàáâãåāăąǎǟǡǻȁȃȧӑӓᾀᾁᾂᾃᾄᾅᾆᾇᾰᾱᾲᾳᾴᾶᾷὰά@4🅐🅰🄰∀Λλ][rRᎡꓣ𝐑𝐫𝑹𝒓ℛℜℝ𝓇𝓡𝓻𝔯𝕣𝕽𝖗𝖱𝗋𝗥𝗿𝘙𝘳𝙍𝙧𝚁𝚛ⓇⓡʀᴿʳŕŗřȑȓɍɹɻɼɽɾṙṛṝṟгГ®🅡🆁🄡][dDᎠꓓ𝐃𝐝𝑫𝒅𝒟𝒹𝓓𝓭𝔇𝔡𝔻d𝕯𝖉𝖣𝖽𝗗𝗱𝘋𝘥𝘿𝙙𝙳𝚍ⒹⓓᴅᴰᵈԀԁɖɗďđðḋḍḏḑḓ🅓🅳🄳ⅅⅆ]+`, []string{"place.stream.richtext.defs#discriminatory"}}, 26 + {`(?i)\b[bBВвᏴꓐ𐊂𐊡𐌁𝐁𝐛𝑩𝒃𝒷𝓑𝓫𝔅𝔟𝔹𝕓𝕭𝖇𝖡𝖻𝗕𝗯𝘉𝘣𝘽𝙗𝙱𝚋𝚩ⒷⓑᴃᴮᵇƀɓЬьβΒḃḅḇ68ßþ🅑🅱🄱ℬ][iIІіӀꓲ𝐈𝐢𝑰𝒊ℐℑ𝒾𝓘𝓲𝔦𝕀𝕚𝕴𝖎𝖨𝗂𝗜𝗶𝘐𝘪𝙄𝙞𝙸𝚒𝚤Ⓘⓘɪᴵⁱᵢìíîïĩīĭįıǐȉȋḭḯỉịӏ1l\|!¡🅘🅸🄸ⅰⅠ][tTТтᎢꓔ𝐓𝐭𝑻𝒕𝒯𝓉𝓣𝓽𝔗𝔱𝕋𝕥𝕿𝖙𝖳𝗍𝗧𝘁𝘛𝘵𝙏𝙩𝚃𝚝Ⓣⓣᴛᵀᵗţťŧțȶṫṭṯṱ7\+†✝🅣🆃🄣][cCСсᏟꓚꮯ𐊢𐌂𐐕𐐽𝐂𝐜𝑪𝒄𝒞𝒸𝓒𝓬ℭ𝔠ℂ𝕔𝕮𝖈𝖢𝖼𝗖𝗰𝘊𝘤𝘾𝙘𝙲𝚌ⒸⓒᴄↃↄϲϹçćĉċčƈȼ¢©€🅒🅲🄲⊂⊃ᑕᑢ\(<\[\{][hHНнᎻꓧ𝐇𝐡𝑯𝒉ℋℌℍ𝒽𝓗𝓱𝔥𝕙𝕳𝖍𝖧𝗁𝗛𝗵𝘏𝘩𝙃𝙝𝙷𝚑Ⓗⓗʜᴴʰĥħȟḣḥḧḩḫհңһ#🅗🅷🄷♄]+`, []string{"place.stream.richtext.defs#profanity"}}, 27 + {`(?i)\b[dDԁɗḋḍḏḑḓdᴅᵈ🅓🅳𝐝𝑑𝒅𝒹𝓭𝔡𝕕𝖉𝖽𝗱𝘥𝙙𝚍ⓓⒹđð][iIіıɪiìíîïĩīĭįǐ1l\|!🅘🅸𝐢𝑖𝒊𝒾𝓲𝔦𝕚𝖎𝗂𝗶𝘪𝙞𝚒ⓘⒾᵢⁱ¡ǃ][cCϲсᴄⅽcçćčĉċ¢©🅒🅲𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ⓒⒸᶜ\(\[\{<ⲥꮯ€🇨][kKκkḱḳḵķᴋᵏ🅚🅺𝐤𝑘𝒌𝓀𝓴𝔨𝕜𝖐𝗄𝗸𝘬𝙠𝚔ⓚⓀꮶ]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 28 + {`(?i)\b[pPрρpṕṗᴘᵖ🅟🅿𝐩𝑝𝒑𝓅𝓹𝔭𝕡𝖕𝗉𝗽𝘱𝙥𝚙ⓟⓅ℘][uUυսuùúûüũūŭůűųưᴜᵘᵤ🅤🆄𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎𝘂𝘶𝙪𝚞ⓤⓊʋꞟꭎꭒ𑣘ט𑜆🇺𝖀vμ][sSѕꜱsśŝşšṡṣṥṧṩˢ\$5🅢🆂𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ⓢⓈ§][sSѕꜱsśŝşšṡṣṥṧṩˢ\$5🅢🆂𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ⓢⓈ§][yYуүγyỳýŷÿỹȳɣʏყᶌỿℽꭚ𑣄¥🅨🆈𝐲𝑦𝒚𝓎𝔂𝔶𝕪𝖞𝗒𝘆𝘺𝙮𝚢ⓨⓎʸ🇾𝖄]+`, []string{"place.stream.richtext.defs#sexually_explicit", "place.stream.richtext.defs#profanity"}}, 29 + {`(?i)\b(f|ƒ|£|🇫|ẝ|ꞙ|ꬵ|🅕|𝖿|𝕱)+(u|v|🇺|ʋ|υ|ս|ᴜ|ꞟ|ꭎ|ꭒ|𑣘|ט|𑜆|🅤|𝗎|𝖀)+(c|\(|€|🇨|©️|ϲ|с|ᴄ|ⲥ|ꮯ|🅒|𝖢|𝕮)+(k|\|<|🇰|🅚|𝕶)+`, []string{"place.stream.richtext.defs#profanity"}}, 30 + // from https://github.com/bluesky-social/atproto/blob/7b9a98a763636c5f66a06da11fe6013f29dd9157/lexicons/app/bsky/richtext/facet.json 31 + {`/\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][hĤĥȞȟḦḧḢḣḨḩḤḥḪḫH̱ẖĦħⱧⱨꞪɦꞕΗНн][iÍíi̇́Ììi̇̀Ĭĭcccccbvnnuugtbekdkibdcrbceidjbticigulkbikbbl 32 + ÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 33 + {`/\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 34 + {`/\b[fḞḟƑƒꞘꞙᵮᶂ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa@4][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGg]{1,2}([ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeiÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ]{1,2}([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 35 + {`/\b[kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLlyÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]*\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 36 + {`/\b[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeaÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ]?|n[ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]|[a4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa]?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 37 + {`/[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?/`, []string{"place.stream.richtext.defs#discriminatory"}}, 38 + {`/\b[tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa4]+[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn]{1,2}([iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]|[yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/`, []string{"place.stream.richtext.defs#discriminatory"}}, 39 + {`(?i)\bANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86\w*`, []string{"place.stream.richtext.defs#profanity"}}, 40 + } 41 + 42 + type patternWithCategories struct { 43 + pattern *regexp.Regexp 44 + categories []string 45 + } 46 + 47 + type Starrer struct { 48 + patterns []patternWithCategories 49 + } 50 + 51 + func NewStarrer(patternDefs []PatternDef) (*Starrer, error) { 52 + patterns := make([]patternWithCategories, 0, len(patternDefs)) 53 + for _, pd := range patternDefs { 54 + re, err := regexp.Compile(pd.Pattern) 55 + if err != nil { 56 + return nil, err 57 + } 58 + patterns = append(patterns, patternWithCategories{ 59 + pattern: re, 60 + categories: pd.Categories, 61 + }) 62 + } 63 + return &Starrer{patterns: patterns}, nil 64 + } 65 + 66 + // NewDefaultStarrer creates a Starrer with the default profanity patterns 67 + func NewDefaultStarrer() (*Starrer, error) { 68 + return NewStarrer(DefaultPatterns) 69 + } 70 + 71 + // LoadPatternsFromJSON loads pattern definitions from a JSON file 72 + func LoadPatternsFromJSON(filepath string) ([]PatternDef, error) { 73 + data, err := os.ReadFile(filepath) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + var patterns []PatternDef 79 + if err := json.Unmarshal(data, &patterns); err != nil { 80 + return nil, err 81 + } 82 + 83 + return patterns, nil 84 + } 85 + 86 + // NewStarrerFromJSON creates a Starrer from a JSON file containing pattern definitions 87 + func NewStarrerFromJSON(filepath string) (*Starrer, error) { 88 + patterns, err := LoadPatternsFromJSON(filepath) 89 + if err != nil { 90 + return nil, err 91 + } 92 + return NewStarrer(patterns) 93 + } 94 + 95 + func (s *Starrer) CensorMessageView(msg *streamplace.ChatDefs_MessageView) (*streamplace.ChatDefs_MessageView, error) { 96 + if msg.Record == nil || msg.Record.ChatDefs_MessageRecordView == nil { 97 + return msg, nil 98 + } 99 + 100 + record := msg.Record.ChatDefs_MessageRecordView 101 + text := record.Text 102 + 103 + // Find all matches across all patterns and create censor facets 104 + var newFacets []*streamplace.RichtextDefs_FacetView 105 + 106 + for _, pwc := range s.patterns { 107 + indices := pwc.pattern.FindAllStringIndex(text, -1) 108 + for _, idx := range indices { 109 + matchedText := text[idx[0]:idx[1]] 110 + byteStart := len([]byte(text[:idx[0]])) 111 + byteEnd := len([]byte(text[:idx[1]])) 112 + 113 + censorFacet := &streamplace.RichtextDefs_FacetView{ 114 + Index: &appbsky.RichtextFacet_ByteSlice{ 115 + ByteStart: int64(byteStart), 116 + ByteEnd: int64(byteEnd), 117 + }, 118 + Features: []*streamplace.RichtextDefs_FacetView_Features_Elem{ 119 + { 120 + RichtextDefs_Censor: &streamplace.RichtextDefs_Censor{ 121 + LexiconTypeID: "place.stream.richtext.defs#censor", 122 + Reason: &matchedText, 123 + Categories: pwc.categories, 124 + }, 125 + }, 126 + }, 127 + } 128 + newFacets = append(newFacets, censorFacet) 129 + } 130 + } 131 + 132 + if len(newFacets) == 0 { 133 + return msg, nil 134 + } 135 + 136 + // Copy the message and add censor facets 137 + censoredMsg := *msg 138 + censoredRecord := *record 139 + censoredRecord.Facets = append(censoredRecord.Facets, newFacets...) 140 + censoredMsg.Record = &streamplace.ChatDefs_MessageView_Record{ 141 + ChatDefs_MessageRecordView: &censoredRecord, 142 + } 143 + 144 + return &censoredMsg, nil 145 + } 146 + 147 + // Censor returns the censored version of the input string 148 + func (s *Starrer) Censor(input string) string { 149 + censored := input 150 + for _, pwc := range s.patterns { 151 + censored = pwc.pattern.ReplaceAllStringFunc(censored, func(match string) string { 152 + return strings.Repeat("*", len(match)) 153 + }) 154 + } 155 + return censored 156 + }
+1 -4
pkg/statedb/queue_processor.go
··· 151 151 return err 152 152 } 153 153 scm := chatTask.MessageView 154 - rec, ok := scm.Record.Val.(*streamplace.ChatMessage) 155 - if !ok { 156 - return fmt.Errorf("invalid chat message record") 157 - } 154 + rec := scm.Record.ChatDefs_MessageRecordView 158 155 159 156 // Send to webhooks using webhook manager 160 157 webhooks, err := state.GetActiveWebhooksForUser(rec.Streamer, "chat")
+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 }
+44 -1
pkg/streamplace/chatdefs.go
··· 12 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 13 ) 14 14 15 + // ChatDefs_MessageRecordView is a "messageRecordView" in the place.stream.chat.defs schema. 16 + // 17 + // The content of a chat message. 18 + type ChatDefs_MessageRecordView struct { 19 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageRecordView"` 20 + // createdAt: Client-declared timestamp when this message was originally created. 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // facets: Annotations of text (mentions, URLs, etc) 23 + Facets []*RichtextDefs_FacetView `json:"facets,omitempty" cborgen:"facets,omitempty"` 24 + Reply *ChatMessage_ReplyRef `json:"reply,omitempty" cborgen:"reply,omitempty"` 25 + // streamer: The DID of the streamer whose chat this is. 26 + Streamer string `json:"streamer" cborgen:"streamer"` 27 + // text: The primary message content. May be an empty string, if there are embeds. 28 + Text string `json:"text" cborgen:"text"` 29 + } 30 + 15 31 // ChatDefs_MessageView is a "messageView" in the place.stream.chat.defs schema. 16 32 type ChatDefs_MessageView struct { 17 33 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` ··· 21 37 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 38 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 39 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 24 - Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` 40 + Record *ChatDefs_MessageView_Record `json:"record" cborgen:"record"` 25 41 ReplyTo *ChatDefs_MessageView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 42 Uri string `json:"uri" cborgen:"uri"` 43 + } 44 + 45 + type ChatDefs_MessageView_Record struct { 46 + ChatDefs_MessageRecordView *ChatDefs_MessageRecordView 47 + } 48 + 49 + func (t *ChatDefs_MessageView_Record) MarshalJSON() ([]byte, error) { 50 + if t.ChatDefs_MessageRecordView != nil { 51 + t.ChatDefs_MessageRecordView.LexiconTypeID = "place.stream.chat.defs#messageRecordView" 52 + return json.Marshal(t.ChatDefs_MessageRecordView) 53 + } 54 + return nil, fmt.Errorf("can not marshal empty union as JSON") 55 + } 56 + 57 + func (t *ChatDefs_MessageView_Record) UnmarshalJSON(b []byte) error { 58 + typ, err := lexutil.TypeExtract(b) 59 + if err != nil { 60 + return err 61 + } 62 + 63 + switch typ { 64 + case "place.stream.chat.defs#messageRecordView": 65 + t.ChatDefs_MessageRecordView = new(ChatDefs_MessageRecordView) 66 + return json.Unmarshal(b, t.ChatDefs_MessageRecordView) 67 + default: 68 + return nil 69 + } 27 70 } 28 71 29 72 type ChatDefs_MessageView_ReplyTo struct {
+74
pkg/streamplace/richtextdefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.richtext.defs 4 + 5 + package streamplace 6 + 7 + import ( 8 + "encoding/json" 9 + "fmt" 10 + 11 + appbsky "github.com/bluesky-social/indigo/api/bsky" 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + ) 14 + 15 + // RichtextDefs_Censor is a "censor" in the place.stream.richtext.defs schema. 16 + // 17 + // Indicates that the text in the given index has been censored. 18 + type RichtextDefs_Censor struct { 19 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.richtext.defs#censor"` 20 + // categories: Categories of censored content 21 + Categories []string `json:"categories,omitempty" cborgen:"categories,omitempty"` 22 + Reason *string `json:"reason,omitempty" cborgen:"reason,omitempty"` 23 + } 24 + 25 + // RichtextDefs_FacetView is a "facetView" in the place.stream.richtext.defs schema. 26 + // 27 + // Annotation of a sub-string within rich text. 28 + type RichtextDefs_FacetView struct { 29 + Features []*RichtextDefs_FacetView_Features_Elem `json:"features" cborgen:"features"` 30 + Index *appbsky.RichtextFacet_ByteSlice `json:"index" cborgen:"index"` 31 + } 32 + 33 + type RichtextDefs_FacetView_Features_Elem struct { 34 + RichtextFacet_Mention *appbsky.RichtextFacet_Mention 35 + RichtextFacet_Link *appbsky.RichtextFacet_Link 36 + RichtextDefs_Censor *RichtextDefs_Censor 37 + } 38 + 39 + func (t *RichtextDefs_FacetView_Features_Elem) MarshalJSON() ([]byte, error) { 40 + if t.RichtextFacet_Mention != nil { 41 + t.RichtextFacet_Mention.LexiconTypeID = "app.bsky.richtext.facet#mention" 42 + return json.Marshal(t.RichtextFacet_Mention) 43 + } 44 + if t.RichtextFacet_Link != nil { 45 + t.RichtextFacet_Link.LexiconTypeID = "app.bsky.richtext.facet#link" 46 + return json.Marshal(t.RichtextFacet_Link) 47 + } 48 + if t.RichtextDefs_Censor != nil { 49 + t.RichtextDefs_Censor.LexiconTypeID = "place.stream.richtext.defs#censor" 50 + return json.Marshal(t.RichtextDefs_Censor) 51 + } 52 + return nil, fmt.Errorf("can not marshal empty union as JSON") 53 + } 54 + 55 + func (t *RichtextDefs_FacetView_Features_Elem) UnmarshalJSON(b []byte) error { 56 + typ, err := lexutil.TypeExtract(b) 57 + if err != nil { 58 + return err 59 + } 60 + 61 + switch typ { 62 + case "app.bsky.richtext.facet#mention": 63 + t.RichtextFacet_Mention = new(appbsky.RichtextFacet_Mention) 64 + return json.Unmarshal(b, t.RichtextFacet_Mention) 65 + case "app.bsky.richtext.facet#link": 66 + t.RichtextFacet_Link = new(appbsky.RichtextFacet_Link) 67 + return json.Unmarshal(b, t.RichtextFacet_Link) 68 + case "place.stream.richtext.defs#censor": 69 + t.RichtextDefs_Censor = new(RichtextDefs_Censor) 70 + return json.Unmarshal(b, t.RichtextDefs_Censor) 71 + default: 72 + return nil 73 + } 74 + }
+100
pnpm-lock.yaml
··· 479 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 ··· 725 728 sharp: 726 729 specifier: ^0.32.5 727 730 version: 0.32.6 731 + starlight-links-validator: 732 + specifier: ^0.19.2 733 + version: 0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 728 734 starlight-openapi: 729 735 specifier: ^0.17.0 730 736 version: 0.17.0(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 731 737 starlight-openapi-rapidoc: 732 738 specifier: ^0.8.1-beta 733 739 version: 0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3) 740 + starlight-sidebar-swipe: 741 + specifier: ^0.1.1 742 + version: 0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 734 743 streamplace: 735 744 specifier: workspace:* 736 745 version: link:../streamplace 746 + devDependencies: 747 + starlight-sidebar-topics: 748 + specifier: ^0.6.2 749 + version: 0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))) 737 750 738 751 js/streamplace: 739 752 dependencies: ··· 4903 4916 '@types/normalize-package-data@2.4.4': 4904 4917 resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 4905 4918 4919 + '@types/picomatch@3.0.2': 4920 + resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} 4921 + 4906 4922 '@types/prop-types@15.7.12': 4907 4923 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} 4908 4924 ··· 7902 7918 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 7903 7919 engines: {node: '>=8'} 7904 7920 7921 + has-flag@5.0.1: 7922 + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} 7923 + engines: {node: '>=12'} 7924 + 7905 7925 has-property-descriptors@1.0.2: 7906 7926 resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 7907 7927 ··· 8351 8371 8352 8372 iron-webcrypto@1.2.1: 8353 8373 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 8374 + 8375 + is-absolute-url@4.0.1: 8376 + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} 8377 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 8354 8378 8355 8379 is-alphabetical@2.0.1: 8356 8380 resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} ··· 11754 11778 standard-as-callback@2.1.0: 11755 11779 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} 11756 11780 11781 + starlight-links-validator@0.19.2: 11782 + resolution: {integrity: sha512-IHeK3R78fsmv53VfRkGbXkwK1CQEUBHM9QPzBEyoAxjZ/ssi5gjV+F4oNNUppTR48iPp+lEY0MTAmvkX7yNnkw==} 11783 + engines: {node: '>=18.17.1'} 11784 + peerDependencies: 11785 + '@astrojs/starlight': '>=0.32.0' 11786 + astro: '>=5.1.5' 11787 + 11757 11788 starlight-openapi-rapidoc@0.8.1-beta: 11758 11789 resolution: {integrity: sha512-CicjuydKZsO8jZvhrtzz+GGWyfHnOZCZY/ww562H58CP/zSnskWcmcgVnCuD7DkKfi0ofvqumyyee1KgfkVipQ==} 11759 11790 engines: {node: '>=18.14.1'} ··· 11768 11799 '@astrojs/markdown-remark': '>=6.0.1' 11769 11800 '@astrojs/starlight': '>=0.34.0' 11770 11801 astro: '>=5.5.0' 11802 + 11803 + starlight-sidebar-swipe@0.1.1: 11804 + resolution: {integrity: sha512-Q+xv7LSpSLCG3yQaEmZX4Qpks9dcIEc+FBA0Ql+LbLMO9IMBXt8S2zK5wJDhjJn5lbI0i0ipyP375T1GrVS8ig==} 11805 + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} 11806 + peerDependencies: 11807 + '@astrojs/starlight': '>=0.30' 11808 + 11809 + starlight-sidebar-topics@0.6.2: 11810 + resolution: {integrity: sha512-SNCTUZS/hcVor0ZcaXbaSVU37+V+qtvzNirkvnOg3Mqu/awuGpthkH5+uKpiZqWxLffp6TrOlsv5E5QsxrndNg==} 11811 + engines: {node: '>=18'} 11812 + peerDependencies: 11813 + '@astrojs/starlight': '>=0.32.0' 11771 11814 11772 11815 statuses@1.5.0: 11773 11816 resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} ··· 11976 12019 resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} 11977 12020 engines: {node: '>= 8.0'} 11978 12021 12022 + supports-color@10.2.2: 12023 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} 12024 + engines: {node: '>=18'} 12025 + 11979 12026 supports-color@5.5.0: 11980 12027 resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 11981 12028 engines: {node: '>=4'} ··· 11992 12039 resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} 11993 12040 engines: {node: '>=8'} 11994 12041 12042 + supports-hyperlinks@4.4.0: 12043 + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} 12044 + engines: {node: '>=20'} 12045 + 11995 12046 supports-preserve-symlinks-flag@1.0.0: 11996 12047 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 11997 12048 engines: {node: '>= 0.4'} ··· 12054 12105 terminal-link@2.1.1: 12055 12106 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 12056 12107 engines: {node: '>=8'} 12108 + 12109 + terminal-link@5.0.0: 12110 + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} 12111 + engines: {node: '>=20'} 12057 12112 12058 12113 terser-webpack-plugin@5.3.10: 12059 12114 resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} ··· 19466 19521 undici-types: 6.21.0 19467 19522 19468 19523 '@types/normalize-package-data@2.4.4': {} 19524 + 19525 + '@types/picomatch@3.0.2': {} 19469 19526 19470 19527 '@types/prop-types@15.7.12': {} 19471 19528 ··· 23318 23375 23319 23376 has-flag@4.0.0: {} 23320 23377 23378 + has-flag@5.0.1: {} 23379 + 23321 23380 has-property-descriptors@1.0.2: 23322 23381 dependencies: 23323 23382 es-define-property: 1.0.0 ··· 23991 24050 ipaddr.js@2.2.0: {} 23992 24051 23993 24052 iron-webcrypto@1.2.1: {} 24053 + 24054 + is-absolute-url@4.0.1: {} 23994 24055 23995 24056 is-alphabetical@2.0.1: {} 23996 24057 ··· 28424 28485 28425 28486 standard-as-callback@2.1.0: {} 28426 28487 28488 + starlight-links-validator@0.19.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)): 28489 + dependencies: 28490 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28491 + '@types/picomatch': 3.0.2 28492 + astro: 5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2) 28493 + github-slugger: 2.0.0 28494 + hast-util-from-html: 2.0.3 28495 + hast-util-has-property: 3.0.0 28496 + is-absolute-url: 4.0.1 28497 + kleur: 4.1.5 28498 + mdast-util-mdx-jsx: 3.2.0 28499 + mdast-util-to-string: 4.0.0 28500 + picomatch: 4.0.2 28501 + terminal-link: 5.0.0 28502 + unist-util-visit: 5.0.0 28503 + transitivePeerDependencies: 28504 + - supports-color 28505 + 28427 28506 starlight-openapi-rapidoc@0.8.1-beta(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)))(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))(openapi-types@12.1.3): 28428 28507 dependencies: 28429 28508 '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) ··· 28444 28523 url-template: 3.1.1 28445 28524 transitivePeerDependencies: 28446 28525 - openapi-types 28526 + 28527 + starlight-sidebar-swipe@0.1.1(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28528 + dependencies: 28529 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28530 + 28531 + starlight-sidebar-topics@0.6.2(@astrojs/starlight@0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2))): 28532 + dependencies: 28533 + '@astrojs/starlight': 0.34.2(astro@5.7.10(@types/node@22.15.17)(encoding@0.1.13)(idb-keyval@6.2.1)(ioredis@5.7.0)(jiti@2.6.1)(lightningcss@1.29.1)(rollup@4.40.1)(terser@5.32.0)(typescript@5.8.3)(yaml@2.8.2)) 28534 + picomatch: 4.0.2 28447 28535 28448 28536 statuses@1.5.0: {} 28449 28537 ··· 28658 28746 transitivePeerDependencies: 28659 28747 - supports-color 28660 28748 28749 + supports-color@10.2.2: {} 28750 + 28661 28751 supports-color@5.5.0: 28662 28752 dependencies: 28663 28753 has-flag: 3.0.0 ··· 28674 28764 dependencies: 28675 28765 has-flag: 4.0.0 28676 28766 supports-color: 7.2.0 28767 + 28768 + supports-hyperlinks@4.4.0: 28769 + dependencies: 28770 + has-flag: 5.0.1 28771 + supports-color: 10.2.2 28677 28772 28678 28773 supports-preserve-symlinks-flag@1.0.0: {} 28679 28774 ··· 28769 28864 dependencies: 28770 28865 ansi-escapes: 4.3.2 28771 28866 supports-hyperlinks: 2.3.0 28867 + 28868 + terminal-link@5.0.0: 28869 + dependencies: 28870 + ansi-escapes: 7.0.0 28871 + supports-hyperlinks: 4.4.0 28772 28872 28773 28873 terser-webpack-plugin@5.3.10(@swc/core@1.15.4(@swc/helpers@0.5.17))(webpack@5.94.0(@swc/core@1.15.4(@swc/helpers@0.5.17))): 28774 28874 dependencies: