Live video on the AT Protocol
1import { AppBskyFeedPost, BlobRef, RichText } from "@atproto/api";
2import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
3import { StreamplaceAgent } from "streamplace/src/agent";
4import { PlaceStreamLivestream } from "streamplace/src/lexicons";
5import { LivestreamViewHydrated } from "streamplace/src/useful-types";
6import { useUrl } from "./streamplace-store";
7import { usePDSAgent } from "./xrpc";
8
9const uploadThumbnail = async (
10 pdsAgent: StreamplaceAgent,
11 customThumbnail?: Blob,
12) => {
13 if (customThumbnail) {
14 let tries = 0;
15 try {
16 let thumbnail = await pdsAgent.uploadBlob(customThumbnail);
17
18 while (
19 thumbnail.data.blob.size === 0 &&
20 customThumbnail.size !== 0 &&
21 tries < 3
22 ) {
23 console.warn(
24 "Reuploading blob as blob sizes don't match! Blob size recieved is",
25 thumbnail.data.blob.size,
26 "and sent blob size is",
27 customThumbnail.size,
28 );
29 thumbnail = await pdsAgent.uploadBlob(customThumbnail);
30 }
31
32 if (tries === 3) {
33 throw new Error("Could not successfully upload blob (tried thrice)");
34 }
35
36 if (thumbnail.success) {
37 console.log("Successfully uploaded thumbnail");
38 return thumbnail.data.blob;
39 }
40 } catch (e) {
41 throw new Error("Error uploading thumbnail: " + e);
42 }
43 }
44};
45
46async function createNewPost(
47 agent: StreamplaceAgent,
48 record: AppBskyFeedPost.Record,
49): Promise<{ uri: string; cid: string }> {
50 try {
51 const post = await agent.post(record);
52
53 return { uri: post.uri, cid: post.cid };
54 } catch (error) {
55 console.error("Error creating new post:", error);
56 throw error;
57 }
58}
59
60function buildGoLivePost(
61 text: string,
62 url: URL,
63 profile: ProfileViewDetailed,
64 params: URLSearchParams,
65 thumbnail: BlobRef | undefined,
66): AppBskyFeedPost.Record {
67 const now = new Date();
68 const linkUrl = `${url.protocol}//${url.host}/${profile.handle}?${params.toString()}`;
69 const prefix = `🔴 LIVE `;
70 const textUrl = `${url.protocol}//${url.host}/${profile.handle}`;
71 const suffix = ` ${text}`;
72 const content = prefix + textUrl + suffix;
73
74 const rt = new RichText({ text: content });
75 rt.detectFacetsWithoutResolution();
76 const record: AppBskyFeedPost.Record = {
77 $type: "app.bsky.feed.post",
78 text: content,
79 "place.stream.livestream": {
80 url: linkUrl,
81 title: text,
82 },
83 facets: rt.facets,
84 createdAt: now.toISOString(),
85 };
86 record.embed = {
87 $type: "app.bsky.embed.external",
88 external: {
89 description: text,
90 thumb: thumbnail,
91 title: `@${profile.handle} is 🔴LIVE on ${url.host}!`,
92 uri: linkUrl,
93 },
94 };
95
96 return record;
97}
98
99export function useCreateStreamRecord() {
100 let agent = usePDSAgent();
101 let url = useUrl();
102
103 return async (
104 title: string,
105 customThumbnail?: Blob,
106 submitPost: boolean = true,
107 ) => {
108 if (!agent) {
109 throw new Error("No PDS agent found");
110 }
111
112 if (!agent.did) {
113 throw new Error("No user DID found, assuming not logged in");
114 }
115
116 let thumbnail: BlobRef | undefined = undefined;
117
118 const u = new URL(url);
119
120 if (customThumbnail) {
121 try {
122 thumbnail = await uploadThumbnail(agent, customThumbnail);
123 } catch (e) {
124 throw new Error(`Custom thumbnail upload failed ${e}`);
125 }
126 } else {
127 // No custom thumbnail: fetch the server-side image and upload it
128 // try thrice lel
129 let tries = 0;
130 try {
131 for (; tries < 3; tries++) {
132 try {
133 console.log(
134 `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`,
135 );
136 const thumbnailRes = await fetch(
137 `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`,
138 );
139 if (!thumbnailRes.ok) {
140 throw new Error(
141 `Failed to fetch thumbnail: ${thumbnailRes.status})`,
142 );
143 }
144 const thumbnailBlob = await thumbnailRes.blob();
145 console.log(thumbnailBlob);
146 thumbnail = await uploadThumbnail(agent, thumbnailBlob);
147 } catch (e) {
148 console.warn(
149 `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`,
150 );
151 // Wait 1 second before retrying
152 await new Promise((resolve) => setTimeout(resolve, 2000));
153 if (tries === 2) {
154 throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`);
155 }
156 }
157 }
158 } catch (e) {
159 throw new Error(`Thumbnail upload failed ${e}`);
160 }
161 }
162
163 let newPost: undefined | { uri: string; cid: string } = undefined;
164
165 if (submitPost) {
166 const did = agent.did;
167 const profile = await agent.getProfile({ actor: did });
168
169 if (!profile) {
170 throw new Error("No profile found for the user DID");
171 }
172
173 const params = new URLSearchParams({
174 did: did,
175 time: new Date().toISOString(),
176 });
177
178 let post = buildGoLivePost(title, u, profile.data, params, thumbnail);
179
180 newPost = await createNewPost(agent, post);
181
182 if (!newPost.uri || !newPost.cid) {
183 throw new Error(
184 "Cannot read properties of undefined (reading 'uri' or 'cid')",
185 );
186 }
187 }
188
189 const record: PlaceStreamLivestream.Record = {
190 title: title,
191 url: url,
192 createdAt: new Date().toISOString(),
193 post: newPost,
194 thumb: thumbnail,
195 };
196
197 await agent.com.atproto.repo.createRecord({
198 repo: agent.did,
199 collection: "place.stream.livestream",
200 record,
201 });
202 return record;
203 };
204}
205
206export function useUpdateStreamRecord() {
207 let agent = usePDSAgent();
208 let url = useUrl();
209
210 return async (
211 title: string,
212 livestream: LivestreamViewHydrated | null,
213 customThumbnail?: Blob,
214 ) => {
215 if (!agent) {
216 throw new Error("No PDS agent found");
217 }
218
219 if (!agent.did) {
220 throw new Error("No user DID found, assuming not logged in");
221 }
222
223 if (!livestream) {
224 throw new Error("No latest record");
225 }
226
227 let rkey = livestream.uri.split("/").pop();
228 let oldRecordValue: PlaceStreamLivestream.Record = livestream.record;
229
230 if (!rkey) {
231 throw new Error("No rkey?");
232 }
233
234 let thumbnail: BlobRef | undefined = oldRecordValue.thumb;
235
236 // update thumbnail if a new one is provided
237 if (customThumbnail) {
238 try {
239 thumbnail = await uploadThumbnail(agent, customThumbnail);
240 } catch (e) {
241 throw new Error(`Custom thumbnail upload failed ${e}`);
242 }
243 }
244
245 const record: PlaceStreamLivestream.Record = {
246 title: title,
247 url: url,
248 createdAt: new Date().toISOString(),
249 post: oldRecordValue.post,
250 thumb: thumbnail,
251 };
252
253 await agent.com.atproto.repo.putRecord({
254 repo: agent.did,
255 collection: "place.stream.livestream",
256 rkey,
257 record,
258 });
259
260 return record;
261 };
262}