diff options
Diffstat (limited to 'packages/server-commands/src/videos/live-command.ts')
-rw-r--r-- | packages/server-commands/src/videos/live-command.ts | 339 |
1 files changed, 339 insertions, 0 deletions
diff --git a/packages/server-commands/src/videos/live-command.ts b/packages/server-commands/src/videos/live-command.ts new file mode 100644 index 000000000..793b64f40 --- /dev/null +++ b/packages/server-commands/src/videos/live-command.ts | |||
@@ -0,0 +1,339 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { readdir } from 'fs/promises' | ||
4 | import { join } from 'path' | ||
5 | import { omit, wait } from '@peertube/peertube-core-utils' | ||
6 | import { | ||
7 | HttpStatusCode, | ||
8 | LiveVideo, | ||
9 | LiveVideoCreate, | ||
10 | LiveVideoSession, | ||
11 | LiveVideoUpdate, | ||
12 | ResultList, | ||
13 | VideoCreateResult, | ||
14 | VideoDetails, | ||
15 | VideoPrivacy, | ||
16 | VideoPrivacyType, | ||
17 | VideoState, | ||
18 | VideoStateType | ||
19 | } from '@peertube/peertube-models' | ||
20 | import { unwrapBody } from '../requests/index.js' | ||
21 | import { ObjectStorageCommand, PeerTubeServer } from '../server/index.js' | ||
22 | import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' | ||
23 | import { sendRTMPStream, testFfmpegStreamError } from './live.js' | ||
24 | |||
25 | export class LiveCommand extends AbstractCommand { | ||
26 | |||
27 | get (options: OverrideCommandOptions & { | ||
28 | videoId: number | string | ||
29 | }) { | ||
30 | const path = '/api/v1/videos/live' | ||
31 | |||
32 | return this.getRequestBody<LiveVideo>({ | ||
33 | ...options, | ||
34 | |||
35 | path: path + '/' + options.videoId, | ||
36 | implicitToken: true, | ||
37 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
38 | }) | ||
39 | } | ||
40 | |||
41 | // --------------------------------------------------------------------------- | ||
42 | |||
43 | listSessions (options: OverrideCommandOptions & { | ||
44 | videoId: number | string | ||
45 | }) { | ||
46 | const path = `/api/v1/videos/live/${options.videoId}/sessions` | ||
47 | |||
48 | return this.getRequestBody<ResultList<LiveVideoSession>>({ | ||
49 | ...options, | ||
50 | |||
51 | path, | ||
52 | implicitToken: true, | ||
53 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
54 | }) | ||
55 | } | ||
56 | |||
57 | async findLatestSession (options: OverrideCommandOptions & { | ||
58 | videoId: number | string | ||
59 | }) { | ||
60 | const { data: sessions } = await this.listSessions(options) | ||
61 | |||
62 | return sessions[sessions.length - 1] | ||
63 | } | ||
64 | |||
65 | getReplaySession (options: OverrideCommandOptions & { | ||
66 | videoId: number | string | ||
67 | }) { | ||
68 | const path = `/api/v1/videos/${options.videoId}/live-session` | ||
69 | |||
70 | return this.getRequestBody<LiveVideoSession>({ | ||
71 | ...options, | ||
72 | |||
73 | path, | ||
74 | implicitToken: true, | ||
75 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
76 | }) | ||
77 | } | ||
78 | |||
79 | // --------------------------------------------------------------------------- | ||
80 | |||
81 | update (options: OverrideCommandOptions & { | ||
82 | videoId: number | string | ||
83 | fields: LiveVideoUpdate | ||
84 | }) { | ||
85 | const { videoId, fields } = options | ||
86 | const path = '/api/v1/videos/live' | ||
87 | |||
88 | return this.putBodyRequest({ | ||
89 | ...options, | ||
90 | |||
91 | path: path + '/' + videoId, | ||
92 | fields, | ||
93 | implicitToken: true, | ||
94 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 | ||
95 | }) | ||
96 | } | ||
97 | |||
98 | async create (options: OverrideCommandOptions & { | ||
99 | fields: LiveVideoCreate | ||
100 | }) { | ||
101 | const { fields } = options | ||
102 | const path = '/api/v1/videos/live' | ||
103 | |||
104 | const attaches: any = {} | ||
105 | if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile | ||
106 | if (fields.previewfile) attaches.previewfile = fields.previewfile | ||
107 | |||
108 | const body = await unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ | ||
109 | ...options, | ||
110 | |||
111 | path, | ||
112 | attaches, | ||
113 | fields: omit(fields, [ 'thumbnailfile', 'previewfile' ]), | ||
114 | implicitToken: true, | ||
115 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
116 | })) | ||
117 | |||
118 | return body.video | ||
119 | } | ||
120 | |||
121 | async quickCreate (options: OverrideCommandOptions & { | ||
122 | saveReplay: boolean | ||
123 | permanentLive: boolean | ||
124 | privacy?: VideoPrivacyType | ||
125 | videoPasswords?: string[] | ||
126 | }) { | ||
127 | const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options | ||
128 | |||
129 | const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED | ||
130 | ? { privacy: VideoPrivacy.PRIVATE } | ||
131 | : { privacy } | ||
132 | |||
133 | const { uuid } = await this.create({ | ||
134 | ...options, | ||
135 | |||
136 | fields: { | ||
137 | name: 'live', | ||
138 | permanentLive, | ||
139 | saveReplay, | ||
140 | replaySettings, | ||
141 | channelId: this.server.store.channel.id, | ||
142 | privacy, | ||
143 | videoPasswords | ||
144 | } | ||
145 | }) | ||
146 | |||
147 | const video = await this.server.videos.getWithToken({ id: uuid }) | ||
148 | const live = await this.get({ videoId: uuid }) | ||
149 | |||
150 | return { video, live } | ||
151 | } | ||
152 | |||
153 | // --------------------------------------------------------------------------- | ||
154 | |||
155 | async sendRTMPStreamInVideo (options: OverrideCommandOptions & { | ||
156 | videoId: number | string | ||
157 | fixtureName?: string | ||
158 | copyCodecs?: boolean | ||
159 | }) { | ||
160 | const { videoId, fixtureName, copyCodecs } = options | ||
161 | const videoLive = await this.get({ videoId }) | ||
162 | |||
163 | return sendRTMPStream({ rtmpBaseUrl: videoLive.rtmpUrl, streamKey: videoLive.streamKey, fixtureName, copyCodecs }) | ||
164 | } | ||
165 | |||
166 | async runAndTestStreamError (options: OverrideCommandOptions & { | ||
167 | videoId: number | string | ||
168 | shouldHaveError: boolean | ||
169 | }) { | ||
170 | const command = await this.sendRTMPStreamInVideo(options) | ||
171 | |||
172 | return testFfmpegStreamError(command, options.shouldHaveError) | ||
173 | } | ||
174 | |||
175 | // --------------------------------------------------------------------------- | ||
176 | |||
177 | waitUntilPublished (options: OverrideCommandOptions & { | ||
178 | videoId: number | string | ||
179 | }) { | ||
180 | const { videoId } = options | ||
181 | return this.waitUntilState({ videoId, state: VideoState.PUBLISHED }) | ||
182 | } | ||
183 | |||
184 | waitUntilWaiting (options: OverrideCommandOptions & { | ||
185 | videoId: number | string | ||
186 | }) { | ||
187 | const { videoId } = options | ||
188 | return this.waitUntilState({ videoId, state: VideoState.WAITING_FOR_LIVE }) | ||
189 | } | ||
190 | |||
191 | waitUntilEnded (options: OverrideCommandOptions & { | ||
192 | videoId: number | string | ||
193 | }) { | ||
194 | const { videoId } = options | ||
195 | return this.waitUntilState({ videoId, state: VideoState.LIVE_ENDED }) | ||
196 | } | ||
197 | |||
198 | async waitUntilSegmentGeneration (options: OverrideCommandOptions & { | ||
199 | server: PeerTubeServer | ||
200 | videoUUID: string | ||
201 | playlistNumber: number | ||
202 | segment: number | ||
203 | objectStorage?: ObjectStorageCommand | ||
204 | objectStorageBaseUrl?: string | ||
205 | }) { | ||
206 | const { | ||
207 | server, | ||
208 | objectStorage, | ||
209 | playlistNumber, | ||
210 | segment, | ||
211 | videoUUID, | ||
212 | objectStorageBaseUrl | ||
213 | } = options | ||
214 | |||
215 | const segmentName = `${playlistNumber}-00000${segment}.ts` | ||
216 | const baseUrl = objectStorage | ||
217 | ? join(objectStorageBaseUrl || objectStorage.getMockPlaylistBaseUrl(), 'hls') | ||
218 | : server.url + '/static/streaming-playlists/hls' | ||
219 | |||
220 | let error = true | ||
221 | |||
222 | while (error) { | ||
223 | try { | ||
224 | // Check fragment exists | ||
225 | await this.getRawRequest({ | ||
226 | ...options, | ||
227 | |||
228 | url: `${baseUrl}/${videoUUID}/${segmentName}`, | ||
229 | implicitToken: false, | ||
230 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
231 | }) | ||
232 | |||
233 | const video = await server.videos.get({ id: videoUUID }) | ||
234 | const hlsPlaylist = video.streamingPlaylists[0] | ||
235 | |||
236 | // Check SHA generation | ||
237 | const shaBody = await server.streamingPlaylists.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry: !!objectStorage }) | ||
238 | if (!shaBody[segmentName]) { | ||
239 | throw new Error('Segment SHA does not exist') | ||
240 | } | ||
241 | |||
242 | // Check fragment is in m3u8 playlist | ||
243 | const subPlaylist = await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/${playlistNumber}.m3u8` }) | ||
244 | if (!subPlaylist.includes(segmentName)) throw new Error('Fragment does not exist in playlist') | ||
245 | |||
246 | error = false | ||
247 | } catch { | ||
248 | error = true | ||
249 | await wait(100) | ||
250 | } | ||
251 | } | ||
252 | } | ||
253 | |||
254 | async waitUntilReplacedByReplay (options: OverrideCommandOptions & { | ||
255 | videoId: number | string | ||
256 | }) { | ||
257 | let video: VideoDetails | ||
258 | |||
259 | do { | ||
260 | video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) | ||
261 | |||
262 | await wait(500) | ||
263 | } while (video.isLive === true || video.state.id !== VideoState.PUBLISHED) | ||
264 | } | ||
265 | |||
266 | // --------------------------------------------------------------------------- | ||
267 | |||
268 | getSegmentFile (options: OverrideCommandOptions & { | ||
269 | videoUUID: string | ||
270 | playlistNumber: number | ||
271 | segment: number | ||
272 | objectStorage?: ObjectStorageCommand | ||
273 | }) { | ||
274 | const { playlistNumber, segment, videoUUID, objectStorage } = options | ||
275 | |||
276 | const segmentName = `${playlistNumber}-00000${segment}.ts` | ||
277 | const baseUrl = objectStorage | ||
278 | ? objectStorage.getMockPlaylistBaseUrl() | ||
279 | : `${this.server.url}/static/streaming-playlists/hls` | ||
280 | |||
281 | const url = `${baseUrl}/${videoUUID}/${segmentName}` | ||
282 | |||
283 | return this.getRawRequest({ | ||
284 | ...options, | ||
285 | |||
286 | url, | ||
287 | implicitToken: false, | ||
288 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
289 | }) | ||
290 | } | ||
291 | |||
292 | getPlaylistFile (options: OverrideCommandOptions & { | ||
293 | videoUUID: string | ||
294 | playlistName: string | ||
295 | objectStorage?: ObjectStorageCommand | ||
296 | }) { | ||
297 | const { playlistName, videoUUID, objectStorage } = options | ||
298 | |||
299 | const baseUrl = objectStorage | ||
300 | ? objectStorage.getMockPlaylistBaseUrl() | ||
301 | : `${this.server.url}/static/streaming-playlists/hls` | ||
302 | |||
303 | const url = `${baseUrl}/${videoUUID}/${playlistName}` | ||
304 | |||
305 | return this.getRawRequest({ | ||
306 | ...options, | ||
307 | |||
308 | url, | ||
309 | implicitToken: false, | ||
310 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
311 | }) | ||
312 | } | ||
313 | |||
314 | // --------------------------------------------------------------------------- | ||
315 | |||
316 | async countPlaylists (options: OverrideCommandOptions & { | ||
317 | videoUUID: string | ||
318 | }) { | ||
319 | const basePath = this.server.servers.buildDirectory('streaming-playlists') | ||
320 | const hlsPath = join(basePath, 'hls', options.videoUUID) | ||
321 | |||
322 | const files = await readdir(hlsPath) | ||
323 | |||
324 | return files.filter(f => f.endsWith('.m3u8')).length | ||
325 | } | ||
326 | |||
327 | private async waitUntilState (options: OverrideCommandOptions & { | ||
328 | videoId: number | string | ||
329 | state: VideoStateType | ||
330 | }) { | ||
331 | let video: VideoDetails | ||
332 | |||
333 | do { | ||
334 | video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) | ||
335 | |||
336 | await wait(500) | ||
337 | } while (video.state.id !== options.state) | ||
338 | } | ||
339 | } | ||