Live video on the AT Protocol

add thumbnails, stream listing page

See merge request aquareum-tv/aquareum!71

Changelog: feature

+802 -109
+9 -4
.gitlab-ci.yml
··· 134 134 build-mac: 135 135 stage: build 136 136 interruptible: true 137 - image: ghcr.io/cirruslabs/macos-runner:sonoma 137 + image: ghcr.io/cirruslabs/macos-runner:sequoia 138 138 tags: 139 139 - tart-installed 140 140 timeout: 2 hours 141 141 script: 142 142 - git fetch --unshallow || echo 'already unshallow' 143 143 - brew install ninja go openssl@3 && go version 144 - - sudo gem install --user-install xcpretty 144 + - sudo gem uninstall xcodeproj -x --ignore-dependencies 145 + - sudo gem install xcodeproj -v 1.25.1 145 146 - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup.sh && bash rustup.sh -y && rm rustup.sh 146 - - export PATH="$PATH:$HOME/.cargo/bin:$(find $HOME/.gem/ruby -type d -name bin -maxdepth 2)" 147 + - export PATH="$PATH:$HOME/.cargo/bin" 148 + - echo $PATH 149 + - 'echo "which xcodeproj: $(which xcodeproj)"' 150 + - 'echo "xcodeproj --version: $(xcodeproj --version)"' 151 + - "$(which xcodeproj) --version" 147 152 - > 148 153 brew install python@3.11 149 154 && python3.11 -m pip install virtualenv ··· 151 156 && source ~/venv/bin/activate 152 157 && pip3 install meson 153 158 && make ci-macos -j16 159 + && make selftest-macos 154 160 155 161 windows-selftest: 156 162 stage: build 157 163 # for the moment... 158 - allow_failure: true 159 164 tags: 160 165 - windows 161 166 timeout: 2 hours
+7 -1
Makefile
··· 135 135 -D "gst-plugins-good:isomp4=enabled" \ 136 136 -D "gst-plugins-good:png=enabled" \ 137 137 -D "gst-plugins-good:videobox=enabled" \ 138 + -D "gst-plugins-good:jpeg=enabled" \ 138 139 -D "gst-plugins-good:audioparsers=enabled" \ 139 140 -D "gst-plugins-bad:videoparsers=enabled" \ 140 141 -D "gst-plugins-bad:mpegtsmux=enabled" \ ··· 142 143 -D "gst-plugins-ugly:gpl=enabled" \ 143 144 -D "x264:asm=enabled" \ 144 145 -D "gstreamer-full:gst-full=enabled" \ 145 - -D "gstreamer-full:gst-full-plugins=libgstaudioresample.a;libgstmatroska.a;libgstmultifile.a;libgstaudiotestsrc.a;libgstaudioconvert.a;libgstaudioparsers.a;libgstfdkaac.a;libgstisomp4.a;libgstapp.a;libgstvideoconvertscale.a;libgstvideobox.a;libgstvideorate.a;libgstpng.a;libgstcompositor.a;libgsthls.a;libgstx264.a;libgstopus.a;libgstvideotestsrc.a;libgstvideoparsersbad.a;libgstaudioparsers.a;libgstmpegtsmux.a;libgstplayback.a;libgsttypefindfunctions.a" \ 146 + -D "gstreamer-full:gst-full-plugins=libgstaudioresample.a;libgstlibav.a;libgstmatroska.a;libgstmultifile.a;libgstjpeg.a;libgstaudiotestsrc.a;libgstaudioconvert.a;libgstaudioparsers.a;libgstfdkaac.a;libgstisomp4.a;libgstapp.a;libgstvideoconvertscale.a;libgstvideobox.a;libgstvideorate.a;libgstpng.a;libgstcompositor.a;libgsthls.a;libgstx264.a;libgstopus.a;libgstvideotestsrc.a;libgstvideoparsersbad.a;libgstaudioparsers.a;libgstmpegtsmux.a;libgstplayback.a;libgsttypefindfunctions.a" \ 146 147 -D "gstreamer-full:gst-full-libraries=gstreamer-controller-1.0,gstreamer-plugins-base-1.0,gstreamer-pbutils-1.0" \ 147 148 -D "gstreamer-full:gst-full-target-type=static_library" \ 148 149 -D "gstreamer-full:gst-full-elements=coreelements:concat,filesrc,filesink,queue,queue2,typefind,tee,filesink,capsfilter,fakesink" \ 149 150 -D "gstreamer-full:bad=enabled" \ 150 151 -D "gstreamer-full:tls=disabled" \ 152 + -D "gstreamer-full:libav=enabled" \ 151 153 -D "gstreamer-full:ugly=enabled" \ 152 154 -D "gstreamer-full:gpl=enabled" \ 153 155 -D "gstreamer-full:gst-full-typefind-functions=" ··· 237 239 && mv js/desktop/out/make/Aquareum-$(VERSION_ELECTRON)-arm64.dmg ./bin/aquareum-desktop-$(VERSION)-darwin-arm64.dmg \ 238 240 && mv js/desktop/out/make/zip/darwin/x64/Aquareum-darwin-x64-$(VERSION_ELECTRON).zip ./bin/aquareum-desktop-$(VERSION)-darwin-amd64.zip \ 239 241 && mv js/desktop/out/make/zip/darwin/arm64/Aquareum-darwin-arm64-$(VERSION_ELECTRON).zip ./bin/aquareum-desktop-$(VERSION)-darwin-arm64.zip 242 + 243 + .PHONY: selftest-macos 244 + selftest-macos: 245 + js/desktop/out/Aquareum-darwin-arm64/Aquareum.app/Contents/MacOS/Aquareum -- --self-test 240 246 241 247 # link your local version of mist for dev 242 248 .PHONY: link-mist
+23 -17
js/app/app.config.ts
··· 50 50 }; 51 51 52 52 export default function () { 53 + const isProd = 54 + process.env["AQ_PRODUCTION_RELEASE"] === "true" || !!process.env.CI; 53 55 const pkg = require("./package.json"); 54 - const name = "Aquareum"; 55 - const bundle = "tv.aquareum"; 56 + const name = isProd ? "Aquareum" : "Devquarium"; 57 + const bundle = isProd ? "tv.aquareum" : "tv.aquareum.dev"; 56 58 return { 57 59 expo: { 58 60 name: name, ··· 74 76 supportsTablet: true, 75 77 bundleIdentifier: bundle, 76 78 googleServicesFile: "./GoogleService-Info.plist", 77 - entitlements: { 78 - "aps-environment": "production", 79 - }, 79 + entitlements: isProd 80 + ? { 81 + "aps-environment": "production", 82 + } 83 + : {}, 80 84 infoPlist: { 81 85 UIBackgroundModes: ["fetch", "remote-notification"], 82 86 LSMinimumSystemVersion: "12.0", ··· 153 157 assets: ["assets"], 154 158 }, 155 159 ], 156 - [withNotificationsIOS, {}], 157 160 [withConsistentVersionNumber, { version: pkg.version }], 161 + ...(isProd ? [[withNotificationsIOS, {}]] : ["expo-dev-launcher"]), 158 162 ], 159 163 experiments: { 160 164 typedRoutes: true, 161 165 }, 162 - updates: { 163 - url: `https://aquareum.tv/api/manifest`, 164 - enabled: true, 165 - checkAutomatically: "ON_LOAD", 166 - fallbackToCacheTimeout: 30000, 167 - codeSigningCertificate: "./code-signing/certs/certificate.pem", 168 - codeSigningMetadata: { 169 - keyid: "main", 170 - alg: "rsa-v1_5-sha256", 171 - }, 172 - }, 166 + updates: isProd 167 + ? { 168 + url: `https://aquareum.tv/api/manifest`, 169 + enabled: true, 170 + checkAutomatically: "ON_LOAD", 171 + fallbackToCacheTimeout: 30000, 172 + codeSigningCertificate: "./code-signing/certs/certificate.pem", 173 + codeSigningMetadata: { 174 + keyid: "main", 175 + alg: "rsa-v1_5-sha256", 176 + }, 177 + } 178 + : {}, 173 179 }, 174 180 }; 175 181 }
+6 -8
js/app/app/(tabs)/index.tsx
··· 47 47 import { useState } from "react"; 48 48 import GetApps from "components/get-apps"; 49 49 import { Link } from "expo-router"; 50 + import StreamList from "components/stream-list/stream-list"; 50 51 51 52 const WebviewIframe = ({ src }) => { 52 53 if (isWeb) { ··· 92 93 }; 93 94 return ( 94 95 <YStack f={1} ai="center" gap="$8" pt="$5" alignItems="stretch"> 95 - <YStack f={1} alignItems="stretch"> 96 + {/* <YStack f={1} alignItems="stretch"> 96 97 <View fg={1} flexBasis={0} onPress={handlePress}> 97 98 {!debug && ( 98 99 <ImageBackground ··· 108 109 </Text> 109 110 ))} 110 111 </View> 111 - </YStack> 112 - <View flexShrink={0} flexGrow={0}> 112 + </YStack> */} 113 + {/* <View flexShrink={0} flexGrow={0}> 113 114 <CenteredH2>Aquareum: The Video Layer for Everything</CenteredH2> 114 115 </View> 115 116 <View> 116 117 <GetApps /> 117 - </View> 118 - <View fg={3} flexBasis={0}> 119 - <WebviewIframe src="https://iame.li" /> 120 - </View> 121 - <View paddingBottom="$10"></View> 118 + </View> */} 119 + <StreamList></StreamList> 122 120 </YStack> 123 121 ); 124 122 }
+18 -1
js/app/app/_layout.tsx
··· 26 26 import { Settings } from "@tamagui/lucide-icons"; 27 27 import { topSafeHeight } from "./platform"; 28 28 import { SafeAreaView } from "react-native"; 29 + import usePlatform from "hooks/usePlatform"; 29 30 30 31 export { 31 32 // Catch any errors thrown by the Layout component. ··· 64 65 65 66 export const LinkNoUnderline = styled(Link, {}); 66 67 68 + // hack to prevent an error we can't do anything about in 69 + // HLS.js from popping up a full screen error page in dev 70 + const IGNORE_THIS_ERROR = 71 + "The fetching process for the media resource was aborted by the user agent at the user's request."; 72 + if (isWeb && typeof window !== "undefined") { 73 + const handler = (e: PromiseRejectionEvent) => { 74 + if (`${e.reason}`.includes(IGNORE_THIS_ERROR)) { 75 + e.preventDefault(); 76 + e.stopPropagation(); 77 + e.stopImmediatePropagation(); 78 + console.error(e); 79 + } 80 + }; 81 + window.addEventListener("unhandledrejection", handler); 82 + } 83 + 67 84 function RootLayoutNav() { 68 85 const colorScheme = useColorScheme(); 69 86 ··· 98 115 }, 99 116 headerLeft: () => ( 100 117 <Anchor href="https://explorer.livepeer.org/treasury/74518185892381909671177921640414850443801430499809418110611019961553289709442"> 101 - <View bg="rgb(189 110 134)" br="$5" padding="$2"> 118 + <View bg="$accentColor" br="$5" padding="$2"> 102 119 <H4 fontSize="$4">What's Aquareum?</H4> 103 120 </View> 104 121 </Anchor>
+10
js/app/components/error/error.tsx
··· 1 + import { View, Text, Button } from "tamagui"; 2 + 3 + export default function (props: { onRetry: () => void }) { 4 + return ( 5 + <View f={1} justifyContent="center" alignItems="center"> 6 + <Text>Unable to contact server.</Text> 7 + <Button onPress={props.onRetry}>Retry?</Button> 8 + </View> 9 + ); 10 + }
+13
js/app/components/loading/loading.tsx
··· 1 + import { View, Text, Spinner as TamaguiSpinner } from "tamagui"; 2 + 3 + export default function () { 4 + return ( 5 + <View f={1} alignItems="center" justifyContent="center"> 6 + <Spinner /> 7 + </View> 8 + ); 9 + } 10 + 11 + export function Spinner() { 12 + return <TamaguiSpinner color="$accentColor" size="large" />; 13 + }
+8 -3
js/app/components/player/fullscreen.native.tsx
··· 1 - import Video from "./video.native"; 2 - import Controls from "./controls"; 3 1 import { VideoView } from "expo-video"; 4 2 import { useRef } from "react"; 3 + import Controls from "./controls"; 4 + import PlayerLoading from "./player-loading"; 5 5 import { PlayerProps } from "./props"; 6 + import Video from "./video.native"; 7 + import VideoRetry from "./video-retry"; 6 8 7 9 export default function Fullscreen(props: PlayerProps) { 8 10 const ref = useRef<VideoView>(null); ··· 18 20 }; 19 21 return ( 20 22 <> 23 + <PlayerLoading {...props}></PlayerLoading> 21 24 <Controls {...props} setFullscreen={setFullscreen} /> 22 - <Video {...props} videoRef={ref} /> 25 + <VideoRetry {...props}> 26 + <Video {...props} videoRef={ref} /> 27 + </VideoRetry> 23 28 </> 24 29 ); 25 30 }
+7 -2
js/app/components/player/fullscreen.tsx
··· 1 1 import { useEffect, useRef } from "react"; 2 2 import { TamaguiElement, View } from "tamagui"; 3 + import Controls from "./controls"; 4 + import PlayerLoading from "./player-loading"; 3 5 import { PlayerProps } from "./props"; 4 6 import Video from "./video"; 5 - import Controls from "./controls"; 7 + import VideoRetry from "./video-retry"; 6 8 7 9 export default function Fullscreen(props: PlayerProps) { 8 10 const ref = useRef<TamaguiElement>(null); ··· 44 46 45 47 return ( 46 48 <View flex={1} ref={ref}> 49 + <PlayerLoading {...props}></PlayerLoading> 47 50 <Controls {...props} setFullscreen={setFullscreen} /> 48 - <Video {...props} /> 51 + <VideoRetry {...props}> 52 + <Video {...props} /> 53 + </VideoRetry> 49 54 </View> 50 55 ); 51 56 }
+22
js/app/components/player/player-loading.tsx
··· 1 + import { Spinner } from "components/loading/loading"; 2 + import { View } from "tamagui"; 3 + import { PlayerProps, PlayerStatus } from "./props"; 4 + 5 + export default function PlayerLoading(props: PlayerProps) { 6 + if (props.status === PlayerStatus.PLAYING) { 7 + return <></>; 8 + } 9 + return ( 10 + <View 11 + position="absolute" 12 + width="100%" 13 + height="100%" 14 + zIndex={998} 15 + alignItems="center" 16 + justifyContent="center" 17 + backgroundColor="rgba(0,0,0,0.8)" 18 + > 19 + <Spinner></Spinner> 20 + </View> 21 + ); 22 + }
+58 -1
js/app/components/player/player.tsx
··· 6 6 import { 7 7 PlayerEvent, 8 8 PlayerProps, 9 + PlayerStatus, 10 + PlayerStatusTracker, 9 11 PROTOCOL_HLS, 10 12 PROTOCOL_PROGRESSIVE_MP4, 11 13 } from "./props"; 12 14 import usePlatform from "hooks/usePlatform"; 13 - import { v7 as uuidv7 } from "uuid"; 14 15 import useAquareumNode from "hooks/useAquareumNode"; 16 + import { uuidv7 } from "hooks/uuid"; 15 17 16 18 const HIDE_CONTROLS_AFTER = 2000; 17 19 ··· 74 76 console.error("error sending player telemetry", e); 75 77 } 76 78 }; 79 + const [status, setStatus] = usePlayerStatus(playerEvent); 77 80 const [protocol, setProtocol] = useState(defProto); 78 81 const [fullscreen, setFullscreen] = useState(false); 79 82 const childProps: PlayerProps = { ··· 90 93 showControls: props.showControls ?? showControls, 91 94 userInteraction: userInteraction, 92 95 playerEvent: playerEvent, 96 + status: status, 97 + setStatus: setStatus, 93 98 }; 94 99 return ( 95 100 <View f={1} justifyContent="center" position="relative"> ··· 97 102 </View> 98 103 ); 99 104 } 105 + 106 + const POLL_INTERVAL = 5000; 107 + export function usePlayerStatus( 108 + playerEvent: ( 109 + time: string, 110 + eventType: string, 111 + meta: { [key: string]: any }, 112 + ) => Promise<void>, 113 + ): [PlayerStatus, (PlayerStatus) => void] { 114 + const [whatDoing, setWhatDoing] = useState<PlayerStatus>(PlayerStatus.START); 115 + const [whatDid, setWhatDid] = useState<PlayerStatusTracker>({}); 116 + const [doingSince, setDoingSince] = useState(Date.now()); 117 + const [lastUpdated, setLastUpdated] = useState(0); 118 + const updateWhatDid = (now: Date): PlayerStatusTracker => { 119 + const prev = whatDid[whatDoing] ?? 0; 120 + const duration = now.getTime() - doingSince; 121 + const ret = { 122 + ...whatDid, 123 + [whatDoing]: prev + duration, 124 + }; 125 + return ret; 126 + }; 127 + const updateStatus = (status: PlayerStatus) => { 128 + const now = new Date(); 129 + if (status !== whatDoing) { 130 + setWhatDid(updateWhatDid(now)); 131 + setWhatDoing(status); 132 + setDoingSince(now.getTime()); 133 + } 134 + }; 135 + 136 + useEffect(() => { 137 + if (lastUpdated === 0) { 138 + return; 139 + } 140 + const now = new Date(); 141 + const fullWhatDid = updateWhatDid(now); 142 + setWhatDid({} as PlayerStatusTracker); 143 + setDoingSince(now.getTime()); 144 + playerEvent(now.toISOString(), "aq-played", { 145 + whatHappened: fullWhatDid, 146 + }); 147 + }, [lastUpdated]); 148 + 149 + useEffect(() => { 150 + const interval = setInterval((_) => { 151 + setLastUpdated(Date.now()); 152 + }, POLL_INTERVAL); 153 + return () => clearInterval(interval); 154 + }, []); 155 + return [whatDoing, updateStatus]; 156 + }
+14 -3
js/app/components/player/props.tsx
··· 7 7 protocol: string; 8 8 showControls: boolean; 9 9 telemetry: boolean; 10 - setMuted: (boolean) => void; 11 - setFullscreen: (boolean) => void; 12 - setProtocol: (string) => void; 10 + setMuted: (isMuted: boolean) => void; 11 + setFullscreen: (isFullscreen: boolean) => void; 12 + setProtocol: (protocol: string) => void; 13 13 userInteraction: () => void; 14 14 playerEvent: ( 15 15 time: string, ··· 17 17 meta: { [key: string]: any }, 18 18 ) => void; 19 19 playerId: string; 20 + status: PlayerStatus; 21 + setStatus: (status: PlayerStatus) => void; 20 22 }; 21 23 22 24 export type PlayerEvent = { ··· 30 32 export const PROTOCOL_HLS = "hls"; 31 33 export const PROTOCOL_PROGRESSIVE_MP4 = "progressive-mp4"; 32 34 export const PROTOCOL_PROGRESSIVE_WEBM = "progressive-webm"; 35 + 36 + export enum PlayerStatus { 37 + START = "start", 38 + PLAYING = "playing", 39 + STALLED = "stalled", 40 + WAITING = "waiting", 41 + } 42 + 43 + export type PlayerStatusTracker = Partial<Record<PlayerStatus, number>>;
+20
js/app/components/player/video-retry.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { PlayerProps, PlayerStatus } from "./props"; 3 + 4 + export default function VideoRetry( 5 + props: PlayerProps & { children: React.ReactNode }, 6 + ) { 7 + const [resetTime, setResetTime] = useState<number>(Date.now()); 8 + const isPlaying = props.status === PlayerStatus.PLAYING; 9 + useEffect(() => { 10 + if (isPlaying) { 11 + return; 12 + } 13 + const handle = setTimeout(() => { 14 + // you've had long enough. try again! 15 + setResetTime(Date.now()); 16 + }, 5000); 17 + return () => clearTimeout(handle); 18 + }, [isPlaying]); 19 + return <React.Fragment key={resetTime}>{props.children}</React.Fragment>; 20 + }
+34 -4
js/app/components/player/video.native.tsx
··· 1 1 import React, { useEffect } from "react"; 2 - import { useVideoPlayer, VideoView } from "expo-video"; 2 + import { useVideoPlayer, VideoPlayerEvents, VideoView } from "expo-video"; 3 3 import useAquareumNode from "hooks/useAquareumNode"; 4 4 import { 5 5 PlayerProps, 6 + PlayerStatus, 6 7 PROTOCOL_HLS, 7 8 PROTOCOL_PROGRESSIVE_MP4, 8 9 PROTOCOL_PROGRESSIVE_WEBM, ··· 17 18 props: PlayerProps & { videoRef: React.RefObject<VideoView> }, 18 19 ) { 19 20 const { url } = srcToUrl(props); 21 + useEffect(() => { 22 + return () => { 23 + props.setStatus(PlayerStatus.START); 24 + }; 25 + }, []); 20 26 const player = useVideoPlayer(url, (player) => { 21 27 player.loop = true; 22 28 player.muted = props.muted; ··· 28 34 }, [props.muted, player]); 29 35 30 36 useEffect(() => { 31 - const subscription = player.addListener("playingChange", (isPlaying) => { 32 - // setIsPlaying(isPlaying); 37 + const subs = ( 38 + [ 39 + "playToEnd", 40 + "playbackRateChange", 41 + "playingChange", 42 + "sourceChange", 43 + "statusChange", 44 + "volumeChange", 45 + ] as (keyof VideoPlayerEvents)[] 46 + ).map((evType) => { 47 + const now = new Date(); 48 + return player.addListener(evType, (...args) => { 49 + props.playerEvent(now.toISOString(), evType, { args: args }); 50 + }); 33 51 }); 34 52 53 + subs.push( 54 + player.addListener("playingChange", (newIsPlaying, oldIsPlaying) => { 55 + if (newIsPlaying) { 56 + props.setStatus(PlayerStatus.PLAYING); 57 + } else { 58 + props.setStatus(PlayerStatus.WAITING); 59 + } 60 + }), 61 + ); 62 + 35 63 return () => { 36 - subscription.remove(); 64 + for (const sub of subs) { 65 + sub.remove(); 66 + } 37 67 }; 38 68 }, [player]); 39 69
+14 -35
js/app/components/player/video.tsx
··· 16 16 import Controls from "./controls"; 17 17 import { 18 18 PlayerProps, 19 + PlayerStatus, 19 20 PROTOCOL_HLS, 20 21 PROTOCOL_PROGRESSIVE_MP4, 21 22 PROTOCOL_PROGRESSIVE_WEBM, ··· 46 47 47 48 const VideoElement = forwardRef( 48 49 (props: VideoProps, ref: ForwardedRef<HTMLVideoElement>) => { 49 - const [whatDoing, setWhatDoing] = useState("start"); 50 - const [whatDid, setWhatDid] = useState<{ [key: string]: number }>({}); 51 - const [doingSince, setDoingSince] = useState(Date.now()); 52 - const [lastUpdated, setLastUpdated] = useState(0); 53 - const updateWhatDid = (now: Date): { [key: string]: number } => { 54 - const prev = whatDid[whatDoing] ?? 0; 55 - const duration = now.getTime() - doingSince; 56 - const ret = { 57 - ...whatDid, 58 - [whatDoing]: prev + duration, 59 - }; 60 - return ret; 61 - }; 62 50 const event = (evType) => (e) => { 63 51 const now = new Date(); 64 - if (updateEvents[evType] && evType !== whatDoing) { 65 - setWhatDid(updateWhatDid(now)); 66 - setWhatDoing(evType); 67 - setDoingSince(now.getTime()); 52 + if (updateEvents[evType]) { 53 + props.setStatus(evType); 68 54 } 69 55 props.playerEvent(now.toISOString(), evType, {}); 70 56 }; 71 57 72 58 useEffect(() => { 73 - if (lastUpdated === 0) { 74 - return; 75 - } 76 - const now = new Date(); 77 - const fullWhatDid = updateWhatDid(now); 78 - setWhatDid({}); 79 - setDoingSince(now.getTime()); 80 - props.playerEvent(now.toISOString(), "aq-played", { 81 - whatHappened: fullWhatDid, 82 - }); 83 - }, [lastUpdated]); 59 + return () => { 60 + props.setStatus(PlayerStatus.START); 61 + }; 62 + }, []); 84 63 85 - useEffect(() => { 86 - const interval = setInterval((_) => { 87 - setLastUpdated(Date.now()); 88 - }, POLL_INTERVAL); 89 - return () => clearInterval(interval); 90 - }, []); 91 64 return ( 92 65 <View 93 66 backgroundColor="#111" ··· 159 132 if (Hls.isSupported()) { 160 133 var hls = new Hls(); 161 134 hls.loadSource(props.url); 162 - hls.attachMedia(videoRef.current); 135 + try { 136 + hls.attachMedia(videoRef.current); 137 + } catch (e) { 138 + console.error("error on attachMedia"); 139 + hls.stopLoad(); 140 + return; 141 + } 163 142 hls.on(Hls.Events.MANIFEST_PARSED, () => { 164 143 if (!videoRef.current) { 165 144 return;
+69
js/app/components/stream-list/stream-list.tsx
··· 1 + import ErrorBox from "components/error/error"; 2 + import Loading from "components/loading/loading"; 3 + import { Link } from "expo-router"; 4 + import useAquareumNode from "hooks/useAquareumNode"; 5 + import { useEffect, useState } from "react"; 6 + import { Pressable } from "react-native"; 7 + import { ScrollView, Text, Image, View, H2, H6 } from "tamagui"; 8 + 9 + type Segment = { 10 + id: string; 11 + user: string; 12 + startTime: string; 13 + endTime: string; 14 + }; 15 + 16 + export default function StreamList() { 17 + const [streams, setStreams] = useState<Segment[]>([]); 18 + const [error, setError] = useState<boolean>(false); 19 + const [loading, setLoading] = useState<boolean>(false); 20 + const [retryTime, setRetryTime] = useState<number>(Date.now()); 21 + const { url } = useAquareumNode(); 22 + useEffect(() => { 23 + setError(false); 24 + setLoading(true); 25 + (async () => { 26 + try { 27 + const res = await fetch(`${url}/api/segment/recent`); 28 + if (!res.ok) { 29 + return; 30 + } 31 + const data = await res.json(); 32 + if (!Array.isArray(data)) { 33 + throw new Error("got non-array back from /api/segment/recent"); 34 + } 35 + setStreams(data); 36 + } catch (e) { 37 + console.error(e); 38 + setError(true); 39 + } finally { 40 + setLoading(false); 41 + } 42 + })(); 43 + }, [url, retryTime]); 44 + if (loading) { 45 + return <Loading></Loading>; 46 + } 47 + if (error) { 48 + return <ErrorBox onRetry={() => setRetryTime(Date.now())} />; 49 + } 50 + return ( 51 + <ScrollView contentContainerStyle={{ alignItems: "center" }}> 52 + {streams.map((seg) => ( 53 + <Link asChild key={seg.user} href={`/stream/${seg.user}`}> 54 + <Pressable> 55 + <View key={seg.user}> 56 + <Image 57 + height={200} 58 + src={`${url}/api/playback/${seg.user}/stream.jpg`} 59 + resizeMode="contain" 60 + objectFit="contain" 61 + /> 62 + <H6>{seg.user}</H6> 63 + </View> 64 + </Pressable> 65 + </Link> 66 + ))} 67 + </ScrollView> 68 + ); 69 + }
+20
js/app/hooks/uuid.tsx
··· 1 + export const uuidv4 = () => { 2 + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { 3 + const r = Math.trunc(Math.random() * 16); 4 + const v = c == "x" ? r : (r & 0x3) | 0x8; 5 + return v.toString(16); 6 + }); 7 + }; 8 + 9 + export const uuidv7 = () => { 10 + return "tttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx" 11 + .replace(/[xy]/g, function (c) { 12 + const r = Math.trunc(Math.random() * 16); 13 + const v = c == "x" ? r : (r & 0x3) | 0x8; 14 + return v.toString(16); 15 + }) 16 + .replace(/^[t]{8}-[t]{4}/, function () { 17 + const unixtimestamp = Date.now().toString(16).padStart(12, "0"); 18 + return unixtimestamp.slice(0, 8) + "-" + unixtimestamp.slice(8); 19 + }); 20 + };
+2 -1
js/app/package.json
··· 12 12 "test": "jest --watchAll", 13 13 "build": "yarn run build:web && yarn run prebuild", 14 14 "build:web": "yarn run export && node exportClientExpoConfig.js > dist/expoConfig.json", 15 - "export": "expo export || expo export", 15 + "export": "expo export --dump-sourcemap || expo export --dump-sourcemap", 16 16 "check": "bash -c 'export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", 17 17 "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=2048m/' android/gradle.properties && echo '\nnetworkTimeout=100000' >> android/gradle.properties && sed -i.bak 's/plugins { id(\"com.facebook.react.settings\") }//' android/settings.gradle && yarn run find-node", 18 18 "postinstall": "which pod && yarn run postinstall-ios || echo 'not a mac, not installing pods'", ··· 37 37 "chrono-node": "^2.7.6", 38 38 "expo": "~51.0.21", 39 39 "expo-build-properties": "^0.12.3", 40 + "expo-dev-client": "~4.0.28", 40 41 "expo-font": "~12.0.9", 41 42 "expo-linking": "~6.3.1", 42 43 "expo-notifications": "~0.28.10",
+7
js/app/tamagui.config.ts
··· 91 91 hoverNone: { hover: "none" }, 92 92 pointerCoarse: { pointer: "coarse" }, 93 93 }, 94 + themes: { 95 + ...configBase.themes, 96 + dark: { 97 + ...configBase.themes.dark, 98 + accentColor: "rgb(189 110 134)", 99 + }, 100 + }, 94 101 }; 95 102 96 103 const config = createTamagui(aquareumConfig);
+43 -9
js/desktop/src/index.ts
··· 1 + import { app, BrowserWindow, dialog } from "electron"; 2 + import { parseArgs } from "node:util"; 3 + import { resolve } from "path"; 1 4 import "source-map-support/register"; 2 - import { app, BrowserWindow, autoUpdater, dialog } from "electron"; 5 + import { v7 as uuidv7 } from "uuid"; 6 + import getEnv from "./env"; 7 + import makeNode from "./node"; 8 + import initUpdater from "./updater"; 3 9 // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack 4 10 // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on 5 11 // whether you're running in development or production). 6 12 declare const MAIN_WINDOW_WEBPACK_ENTRY: string; 7 13 declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; 8 - import makeNode from "./node"; 9 - import getEnv from "./env"; 10 - import initUpdater from "./updater"; 11 - import path, { resolve } from "path"; 12 - import { parseArgs } from "node:util"; 13 - import { v7 as uuidv7 } from "uuid"; 14 14 15 15 // Handle creating/removing shortcuts on Windows when installing/uninstalling. 16 16 if (require("electron-squirrel-startup")) { ··· 27 27 }, 28 28 "self-test-duration": { 29 29 type: "string", 30 - default: "60000", 30 + default: "300000", 31 31 }, 32 32 }, 33 33 }); ··· 110 110 111 111 const runSelfTest = async (): Promise<void> => { 112 112 let exitCode = 0; 113 + const { nodeFrontend } = getEnv(); 113 114 const { addr, internalAddr, proc } = await makeNode({ 114 115 env: { 115 116 AQ_TEST_STREAM: "true", ··· 142 143 })); 143 144 const enc = encodeURIComponent(JSON.stringify(tests)); 144 145 145 - mainWindow.loadURL(`${addr}/multi/${enc}`); 146 + if (nodeFrontend) { 147 + mainWindow.loadURL(`${addr}/multi/${enc}`); 148 + } else { 149 + mainWindow.loadURL(`http://localhost:38081/multi/${enc}`); 150 + } 151 + 152 + let foundThumbnail = false; 153 + const interval = setInterval(async () => { 154 + const res = await fetch(`${addr}/api/playback/self-test/stream.jpg`); 155 + if (res.status === 404) { 156 + console.log("no thumbnail found"); 157 + return; 158 + } 159 + if (res.status !== 200) { 160 + console.log( 161 + `unexpected http status ${res.status}, failing thumbnail test`, 162 + ); 163 + clearInterval(interval); 164 + return; 165 + } 166 + const blob = await res.arrayBuffer(); 167 + if (blob.byteLength < 1) { 168 + console.log("thumbnail was empty :("); 169 + return; 170 + } 171 + console.log("found thumbnail!"); 172 + foundThumbnail = true; 173 + clearInterval(interval); 174 + }, 1000); 146 175 147 176 await delay(parseInt(args["self-test-duration"])); 177 + clearInterval(interval); 148 178 const reports = await Promise.all( 149 179 tests.map(async (t) => { 150 180 const res = await fetch( ··· 155 185 }), 156 186 ); 157 187 let failed = false; 188 + if (!foundThumbnail) { 189 + console.log("never found a thumbnail, failing test"); 190 + failed = true; 191 + } 158 192 const percentages = reports.map((report) => { 159 193 let total = 0; 160 194 for (const [state, ms] of Object.entries(report.data)) {
+19
pkg/api/api.go
··· 111 111 apiRouter.GET("/api/playback/:user/stream.mp4", a.HandleMP4Playback(ctx)) 112 112 apiRouter.GET("/api/playback/:user/stream.webm", a.HandleMKVPlayback(ctx)) 113 113 apiRouter.GET("/api/playback/:user/hls/:file", a.HandleHLSPlayback(ctx)) 114 + apiRouter.GET("/api/playback/:user/stream.jpg", a.HandleThumbnailPlayback(ctx)) 114 115 apiRouter.POST("/api/player-event", a.HandlePlayerEvent(ctx)) 116 + apiRouter.GET("/api/segment/recent", a.HandleRecentSegments(ctx)) 115 117 apiRouter.NotFound = a.HandleAPI404(ctx) 116 118 router.Handler("GET", "/api/*resource", apiRouter) 117 119 router.Handler("POST", "/api/*resource", apiRouter) ··· 319 321 return 320 322 } 321 323 w.WriteHeader(201) 324 + } 325 + } 326 + 327 + func (a *AquareumAPI) HandleRecentSegments(ctx context.Context) httprouter.Handle { 328 + return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { 329 + segs, err := a.Model.MostRecentSegments() 330 + if err != nil { 331 + apierrors.WriteHTTPInternalServerError(w, "could not get segments", err) 332 + return 333 + } 334 + bs, err := json.Marshal(segs) 335 + if err != nil { 336 + apierrors.WriteHTTPInternalServerError(w, "could not marshal segments", err) 337 + return 338 + } 339 + w.Header().Add("Content-Type", "application/json") 340 + w.Write(bs) 322 341 } 323 342 } 324 343
+38
pkg/api/playback.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "fmt" 6 7 "io" 7 8 "net/http" 8 9 "path/filepath" ··· 10 11 "strings" 11 12 "time" 12 13 14 + "aquareum.tv/aquareum/pkg/aqtime" 13 15 "aquareum.tv/aquareum/pkg/errors" 14 16 "github.com/julienschmidt/httprouter" 15 17 "golang.org/x/sync/errgroup" ··· 125 127 http.ServeFile(w, r, fullpath) 126 128 } 127 129 } 130 + 131 + func (a *AquareumAPI) HandleThumbnailPlayback(ctx context.Context) httprouter.Handle { 132 + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 133 + user := p.ByName("user") 134 + if user == "" { 135 + errors.WriteHTTPBadRequest(w, "user required", nil) 136 + return 137 + } 138 + user = a.NormalizeUser(user) 139 + thumb, err := a.Model.LatestThumbnailForUser(user) 140 + if err != nil { 141 + errors.WriteHTTPInternalServerError(w, "could not query thumbnail", err) 142 + return 143 + } 144 + if thumb == nil { 145 + errors.WriteHTTPNotFound(w, "thumbnail not found", err) 146 + return 147 + } 148 + aqt := aqtime.FromTime(thumb.Segment.StartTime) 149 + fpath, err := a.CLI.SegmentFilePath(user, fmt.Sprintf("%s.%s", aqt.String(), thumb.Format)) 150 + if err != nil { 151 + errors.WriteHTTPInternalServerError(w, "could not get segment file path", err) 152 + return 153 + } 154 + http.ServeFile(w, r, fpath) 155 + 156 + // getDir, err := a.MediaManager.SegmentToHLSOnce(ctx, user) 157 + // if err != nil { 158 + // errors.WriteHTTPInternalServerError(w, "SegmentToHLSOnce failed", nil) 159 + // return 160 + // } 161 + // dir := getDir() 162 + // fullpath := filepath.Join(dir, file) 163 + // http.ServeFile(w, r, fullpath) 164 + } 165 + }
+4
pkg/aqtime/aqtime.go
··· 36 36 return AQTime(str), nil 37 37 } 38 38 39 + func FromTime(t time.Time) AQTime { 40 + return AQTime(t.UTC().Format(fstr)) 41 + } 42 + 39 43 // year, month, day, hour, min, sec, millisecond 40 44 func (aqt AQTime) Parts() (string, string, string, string, string, string, string) { 41 45 bits := RE.FindStringSubmatch(aqt.String())
+52
pkg/cmd/aquareum.go
··· 1 1 package cmd 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "crypto" 6 7 "flag" ··· 12 13 "runtime/pprof" 13 14 "strconv" 14 15 "syscall" 16 + "time" 15 17 16 18 "aquareum.tv/aquareum/pkg/aqhttp" 19 + "aquareum.tv/aquareum/pkg/aqtime" 17 20 "aquareum.tv/aquareum/pkg/crypto/signers" 18 21 "aquareum.tv/aquareum/pkg/crypto/signers/eip712" 19 22 "aquareum.tv/aquareum/pkg/log" ··· 257 260 258 261 group.Go(func() error { 259 262 return a.ServeInternalHTTP(ctx) 263 + }) 264 + 265 + group.Go(func() error { 266 + newSeg := mm.NewSegment() 267 + for { 268 + select { 269 + case <-ctx.Done(): 270 + return nil 271 + case not := <-newSeg: 272 + err := mod.CreateSegment(not.Segment) 273 + if err != nil { 274 + log.Error(ctx, "could not add segment to database", "error", err) 275 + } 276 + go func() { 277 + err := func() error { 278 + oldThumb, err := mod.LatestThumbnailForUser(not.Segment.User) 279 + if err != nil { 280 + return err 281 + } 282 + if oldThumb != nil && not.Segment.StartTime.Sub(oldThumb.Segment.StartTime) < time.Minute { 283 + // we have a thumbnail <60sec old, skip generating a new one 284 + return nil 285 + } 286 + r := bytes.NewReader(not.Data) 287 + aqt := aqtime.FromTime(not.Segment.StartTime) 288 + fd, err := cli.SegmentFileCreate(not.Segment.User, aqt, "jpg") 289 + if err != nil { 290 + return err 291 + } 292 + err = mm.Thumbnail(ctx, r, fd) 293 + if err != nil { 294 + return err 295 + } 296 + thumb := &model.Thumbnail{ 297 + Format: "jpg", 298 + SegmentID: not.Segment.ID, 299 + } 300 + err = mod.CreateThumbnail(thumb) 301 + if err != nil { 302 + return err 303 + } 304 + return nil 305 + }() 306 + if err != nil { 307 + log.Error(ctx, "could not create thumbnail", "error", err) 308 + } 309 + }() 310 + } 311 + } 260 312 }) 261 313 262 314 if cli.TestStream {
-6
pkg/config/config.go
··· 204 204 // get a path to a segment file in our database 205 205 func (cli *CLI) SegmentFilePath(user string, file string) (string, error) { 206 206 ext := filepath.Ext(file) 207 - if ext != ".mp4" { 208 - return "", fmt.Errorf("expected mp4 ext, got %s", ext) 209 - } 210 207 base := strings.TrimSuffix(file, ext) 211 208 aqt, err := aqtime.FromString(base) 212 209 if err != nil { ··· 224 221 225 222 // create a segment file in our database 226 223 func (cli *CLI) SegmentFileCreate(user string, aqt aqtime.AQTime, ext string) (*os.File, error) { 227 - if ext != "mp4" { 228 - return nil, fmt.Errorf("expected mp4 ext, got %s", ext) 229 - } 230 224 fname := fmt.Sprintf("%s.%s", aqt.FileSafeString(), ext) 231 225 yr, mon, day, hr, min, _, _ := aqt.Parts() 232 226 return cli.DataFileCreate([]string{SEGMENTS_DIR, user, yr, mon, day, hr, min, fname}, false)
+71 -5
pkg/media/gstreamer.go
··· 242 242 243 243 mainLoop.Run() 244 244 245 - if err != nil { 246 - return err 247 - } 248 245 if len(output.Bytes()) < 1 { 249 246 return fmt.Errorf("got a zero-byte buffer from SelfTest") 250 247 } ··· 499 496 log.Log(ctx, "gstreamer debug", "message", debug) 500 497 } 501 498 cancel() 502 - // default: 503 - // log.Log(ctx, msg.String()) 499 + default: 500 + log.Debug(ctx, msg.String()) 504 501 } 505 502 return true 506 503 }) ··· 567 564 568 565 return elem, nil 569 566 } 567 + 568 + func (mm *MediaManager) Thumbnail(ctx context.Context, r io.Reader, w io.Writer) error { 569 + ctx = log.WithLogValues(ctx, "function", "Thumbnail") 570 + mainLoop := glib.NewMainLoop(glib.MainContextDefault(), false) 571 + 572 + pipelineSlice := []string{ 573 + "appsrc name=appsrc ! qtdemux ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=[1,200],height=[1,200],pixel-aspect-ratio=1/1 ! pngenc ! appsink name=appsink", 574 + } 575 + 576 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 577 + if err != nil { 578 + return fmt.Errorf("error creating TestSource pipeline: %w", err) 579 + } 580 + appsrc, err := pipeline.GetElementByName("appsrc") 581 + if err != nil { 582 + return err 583 + } 584 + 585 + src := app.SrcFromElement(appsrc) 586 + src.SetCallbacks(&app.SourceCallbacks{ 587 + NeedDataFunc: readerNeedData(ctx, r), 588 + }) 589 + 590 + ctx, cancel := context.WithCancel(ctx) 591 + defer cancel() 592 + 593 + appsink, err := pipeline.GetElementByName("appsink") 594 + if err != nil { 595 + return err 596 + } 597 + 598 + pipeline.GetPipelineBus().AddWatch(func(msg *gst.Message) bool { 599 + switch msg.Type() { 600 + 601 + case gst.MessageEOS: // When end-of-stream is received flush the pipeling and stop the main loop 602 + cancel() 603 + case gst.MessageError: // Error messages are always fatal 604 + err := msg.ParseError() 605 + log.Log(ctx, "gstreamer error", "error", err.Error()) 606 + if debug := err.DebugString(); debug != "" { 607 + log.Log(ctx, "gstreamer debug", "message", debug) 608 + } 609 + cancel() 610 + default: 611 + log.Debug(ctx, msg.String()) 612 + } 613 + return true 614 + }) 615 + 616 + sink := app.SinkFromElement(appsink) 617 + sink.SetCallbacks(&app.SinkCallbacks{ 618 + NewSampleFunc: writerNewSample(ctx, w), 619 + EOSFunc: func(sink *app.Sink) { 620 + cancel() 621 + }, 622 + }) 623 + 624 + pipeline.SetState(gst.StatePlaying) 625 + 626 + go func() { 627 + <-ctx.Done() 628 + pipeline.BlockSetState(gst.StateNull) 629 + mainLoop.Quit() 630 + }() 631 + 632 + mainLoop.Run() 633 + 634 + return nil 635 + }
+11
pkg/media/gstreamer_test.go
··· 24 24 require.NoError(t, err) 25 25 require.Greater(t, info.Size(), int64(0)) 26 26 } 27 + 28 + // func TestThumbnail(t *testing.T) { 29 + // mm := MediaManager{} 30 + // gst.Init(nil) 31 + // ifile, err := os.Open(getFixture("sample-segment.mp4")) 32 + // require.NoError(t, err) 33 + // buf := &bytes.Buffer{} 34 + // err = mm.Thumbnail(context.Background(), ifile, buf) 35 + // require.NoError(t, err) 36 + // require.Greater(t, len(buf.Bytes()), 0) 37 + // }
+40 -8
pkg/media/media.go
··· 17 17 "aquareum.tv/aquareum/pkg/config" 18 18 "aquareum.tv/aquareum/pkg/crypto/signers" 19 19 "aquareum.tv/aquareum/pkg/log" 20 + "aquareum.tv/aquareum/pkg/model" 20 21 "aquareum.tv/aquareum/pkg/replication" 21 22 "github.com/go-gst/go-gst/gst" 22 23 "github.com/google/uuid" ··· 36 37 const SCHEMA_ORG_END_TIME = "http://schema.org/endTime" 37 38 38 39 type MediaManager struct { 39 - cli *config.CLI 40 - mp4subs map[string][]chan string 41 - mp4subsmut sync.Mutex 42 - replicator replication.Replicator 43 - hlsRunning map[string]HLSStream 44 - hlsRunningMut sync.Mutex 45 - httpPipes map[string]io.Writer 46 - httpPipesMutex sync.Mutex 40 + cli *config.CLI 41 + mp4subs map[string][]chan string 42 + mp4subsmut sync.Mutex 43 + replicator replication.Replicator 44 + hlsRunning map[string]HLSStream 45 + hlsRunningMut sync.Mutex 46 + httpPipes map[string]io.Writer 47 + httpPipesMutex sync.Mutex 48 + newSegmentSubs []chan *NewSegmentNotification 49 + newSegmentSubsMutex sync.RWMutex 50 + } 51 + 52 + type NewSegmentNotification struct { 53 + Segment *model.Segment 54 + Data []byte 47 55 } 48 56 49 57 type HLSStream struct { ··· 94 102 mm.httpPipesMutex.Lock() 95 103 defer mm.httpPipesMutex.Unlock() 96 104 return mm.httpPipes[uu] 105 + } 106 + 107 + // register a handler for all new segments that come in 108 + func (mm *MediaManager) NewSegment() <-chan *NewSegmentNotification { 109 + ch := make(chan *NewSegmentNotification) 110 + mm.newSegmentSubsMutex.Lock() 111 + defer mm.newSegmentSubsMutex.Unlock() 112 + mm.newSegmentSubs = append(mm.newSegmentSubs, ch) 113 + return ch 97 114 } 98 115 99 116 // subscribe to the latest segments from a given user for livestreaming purposes ··· 370 387 io.Copy(fd, r) 371 388 base := filepath.Base(fd.Name()) 372 389 go mm.PublishSegment(ctx, pub.String(), base) 390 + seg := &model.Segment{ 391 + ID: *mani.Label, 392 + User: pub.String(), 393 + StartTime: meta.StartTime.Time(), 394 + EndTime: meta.EndTime.Time(), 395 + } 396 + mm.newSegmentSubsMutex.RLock() 397 + defer mm.newSegmentSubsMutex.RUnlock() 398 + not := &NewSegmentNotification{ 399 + Segment: seg, 400 + Data: buf, 401 + } 402 + for _, ch := range mm.newSegmentSubs { 403 + go func() { ch <- not }() 404 + } 373 405 log.Log(ctx, "successfully ingested segment", "user", pub.String(), "timestamp", meta.StartTime) 374 406 return nil 375 407 }
+7 -1
pkg/model/model.go
··· 27 27 ListPlayerEvents(playerId string) ([]PlayerEvent, error) 28 28 PlayerReport(playerId string) (map[string]float64, error) 29 29 ClearPlayerEvents() error 30 + 31 + CreateSegment(segment *Segment) error 32 + MostRecentSegments() ([]Segment, error) 33 + 34 + CreateThumbnail(thumb *Thumbnail) error 35 + LatestThumbnailForUser(user string) (*Thumbnail, error) 30 36 } 31 37 32 38 func MakeDB(dbURL string) (Model, error) { ··· 54 60 if err != nil { 55 61 return nil, fmt.Errorf("error starting database: %w", err) 56 62 } 57 - for _, model := range []any{Notification{}, PlayerEvent{}} { 63 + for _, model := range []any{Notification{}, PlayerEvent{}, Segment{}, Thumbnail{}} { 58 64 err = db.AutoMigrate(model) 59 65 if err != nil { 60 66 return nil, err
+36
pkg/model/segment.go
··· 1 + package model 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Segment struct { 8 + ID string `json:"id" gorm:"primaryKey"` 9 + User string `json:"user" gorm:"index:latest_segments"` 10 + StartTime time.Time `json:"startTime" gorm:"index:latest_segments"` 11 + EndTime time.Time `json:"endTime"` 12 + } 13 + 14 + func (m *DBModel) CreateSegment(seg *Segment) error { 15 + err := m.DB.Model(Segment{}).Create(seg).Error 16 + if err != nil { 17 + return err 18 + } 19 + return nil 20 + } 21 + 22 + // should return the most recent segment for each user 23 + func (m *DBModel) MostRecentSegments() ([]Segment, error) { 24 + var segments []Segment 25 + 26 + err := m.DB.Table("segments AS s1"). 27 + Select("s1.*"). 28 + Where("start_time = (SELECT MAX(start_time) FROM segments AS s2 WHERE s2.user = s1.user)"). 29 + Scan(&segments).Error 30 + 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + return segments, nil 36 + }
+56
pkg/model/thumbnail.go
··· 1 + package model 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/google/uuid" 7 + ) 8 + 9 + type Thumbnail struct { 10 + ID string `json:"id" gorm:"primaryKey"` 11 + Format string `json:"format"` 12 + SegmentID string `json:"segmentId" gorm:"index"` 13 + Segment Segment `json:"segment,omitempty" gorm:"foreignKey:SegmentID;references:id"` 14 + } 15 + 16 + func (m *DBModel) CreateThumbnail(thumb *Thumbnail) error { 17 + uu, err := uuid.NewV7() 18 + if err != nil { 19 + return err 20 + } 21 + thumb.ID = uu.String() 22 + err = m.DB.Model(Thumbnail{}).Create(thumb).Error 23 + if err != nil { 24 + return err 25 + } 26 + return nil 27 + } 28 + 29 + // return the most recent thumbnail for a user 30 + func (m *DBModel) LatestThumbnailForUser(user string) (*Thumbnail, error) { 31 + var thumbnail Thumbnail 32 + 33 + res := m.DB.Table("thumbnails AS t"). 34 + Select("t.*"). 35 + Joins("JOIN segments AS s ON t.segment_id = s.id"). 36 + Where("s.user = ?", user). 37 + Order("s.start_time DESC"). 38 + Limit(1). 39 + Scan(&thumbnail) 40 + if res.Error != nil { 41 + return nil, res.Error 42 + } 43 + if res.RowsAffected == 0 { 44 + return nil, nil 45 + } 46 + 47 + var seg Segment 48 + err := m.DB.First(&seg, "id = ?", thumbnail.SegmentID).Error 49 + if err != nil { 50 + return nil, fmt.Errorf("could not find segment for thumbnail SegmentID=%s", thumbnail.SegmentID) 51 + } 52 + 53 + thumbnail.Segment = seg 54 + 55 + return &thumbnail, nil 56 + }
+64
yarn.lock
··· 10316 10316 languageName: node 10317 10317 linkType: hard 10318 10318 10319 + "ajv@npm:8.11.0": 10320 + version: 8.11.0 10321 + resolution: "ajv@npm:8.11.0" 10322 + dependencies: 10323 + fast-deep-equal: "npm:^3.1.1" 10324 + json-schema-traverse: "npm:^1.0.0" 10325 + require-from-string: "npm:^2.0.2" 10326 + uri-js: "npm:^4.2.2" 10327 + checksum: 10/aa0dfd6cebdedde8e77747e84e7b7c55921930974b8547f54b4156164ff70445819398face32dafda4bd4c61bbc7513d308d4c2bf769f8ea6cb9c8449f9faf54 10328 + languageName: node 10329 + linkType: hard 10330 + 10319 10331 "ajv@npm:^6.12.4, ajv@npm:^6.12.5": 10320 10332 version: 6.12.6 10321 10333 resolution: "ajv@npm:6.12.6" ··· 10584 10596 chrono-node: "npm:^2.7.6" 10585 10597 expo: "npm:~51.0.21" 10586 10598 expo-build-properties: "npm:^0.12.3" 10599 + expo-dev-client: "npm:~4.0.28" 10587 10600 expo-font: "npm:~12.0.9" 10588 10601 expo-linking: "npm:~6.3.1" 10589 10602 expo-notifications: "npm:~0.28.10" ··· 14448 14461 peerDependencies: 14449 14462 expo: "*" 14450 14463 checksum: 10/f2f8b15932ab2f805544fd96c6740d2354c6409706eee2664be1703c3480c7531a709112af811dc22f05c164c06501aec20f81617675e87b5a3b66ab5b8d7611 14464 + languageName: node 14465 + linkType: hard 14466 + 14467 + "expo-dev-client@npm:~4.0.28": 14468 + version: 4.0.28 14469 + resolution: "expo-dev-client@npm:4.0.28" 14470 + dependencies: 14471 + expo-dev-launcher: "npm:4.0.28" 14472 + expo-dev-menu: "npm:5.0.22" 14473 + expo-dev-menu-interface: "npm:1.8.3" 14474 + expo-manifests: "npm:~0.14.0" 14475 + expo-updates-interface: "npm:~0.16.2" 14476 + peerDependencies: 14477 + expo: "*" 14478 + checksum: 10/e091be8975d843a98e65f1d5cf05bdb5f7673d2dd332545198f5629f047bd5a68516800e0347de1f73650e0ea58741f089b2d0fab8939b759680d682d94bb924 14479 + languageName: node 14480 + linkType: hard 14481 + 14482 + "expo-dev-launcher@npm:4.0.28": 14483 + version: 4.0.28 14484 + resolution: "expo-dev-launcher@npm:4.0.28" 14485 + dependencies: 14486 + ajv: "npm:8.11.0" 14487 + expo-dev-menu: "npm:5.0.22" 14488 + expo-manifests: "npm:~0.14.0" 14489 + resolve-from: "npm:^5.0.0" 14490 + semver: "npm:^7.6.0" 14491 + peerDependencies: 14492 + expo: "*" 14493 + checksum: 10/6c38d4c6c8a134ec56783d534544bb3e0f0423418d01158ba8a7331be30898cf69b5d71f20bb3c2672e29de30004bcdc70b4064386efa4f2d35ae5d95ace86af 14494 + languageName: node 14495 + linkType: hard 14496 + 14497 + "expo-dev-menu-interface@npm:1.8.3": 14498 + version: 1.8.3 14499 + resolution: "expo-dev-menu-interface@npm:1.8.3" 14500 + peerDependencies: 14501 + expo: "*" 14502 + checksum: 10/c63b7a1c2e7591085527a7944f3a9eaf6fc77d845400c5d064518ef06904ab193b77020e5fe029ff2897e359a07217ff81b7074cbfd1435780cf0a0b5bc8cf17 14503 + languageName: node 14504 + linkType: hard 14505 + 14506 + "expo-dev-menu@npm:5.0.22": 14507 + version: 5.0.22 14508 + resolution: "expo-dev-menu@npm:5.0.22" 14509 + dependencies: 14510 + expo-dev-menu-interface: "npm:1.8.3" 14511 + semver: "npm:^7.5.4" 14512 + peerDependencies: 14513 + expo: "*" 14514 + checksum: 10/9ff91603cefc203a45733eadca589f92a2063edec4fcd77350b42c4b2e084c0e2de89c559c0ab209366b053187a189435717323dbc6b6622e46a94b446b6fa64 14451 14515 languageName: node 14452 14516 linkType: hard 14453 14517