diff options
Diffstat (limited to 'packages/tests/src/shared/streaming-playlists.ts')
-rw-r--r-- | packages/tests/src/shared/streaming-playlists.ts | 302 |
1 files changed, 302 insertions, 0 deletions
diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts new file mode 100644 index 000000000..f2f0fbe85 --- /dev/null +++ b/packages/tests/src/shared/streaming-playlists.ts | |||
@@ -0,0 +1,302 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { expect } from 'chai' | ||
4 | import { basename, dirname, join } from 'path' | ||
5 | import { removeFragmentedMP4Ext, uuidRegex } from '@peertube/peertube-core-utils' | ||
6 | import { | ||
7 | HttpStatusCode, | ||
8 | VideoPrivacy, | ||
9 | VideoResolution, | ||
10 | VideoStreamingPlaylist, | ||
11 | VideoStreamingPlaylistType | ||
12 | } from '@peertube/peertube-models' | ||
13 | import { sha256 } from '@peertube/peertube-node-utils' | ||
14 | import { makeRawRequest, PeerTubeServer } from '@peertube/peertube-server-commands' | ||
15 | import { expectStartWith } from './checks.js' | ||
16 | import { hlsInfohashExist } from './tracker.js' | ||
17 | import { checkWebTorrentWorks } from './webtorrent.js' | ||
18 | |||
19 | async function checkSegmentHash (options: { | ||
20 | server: PeerTubeServer | ||
21 | baseUrlPlaylist: string | ||
22 | baseUrlSegment: string | ||
23 | resolution: number | ||
24 | hlsPlaylist: VideoStreamingPlaylist | ||
25 | token?: string | ||
26 | }) { | ||
27 | const { server, baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist, token } = options | ||
28 | const command = server.streamingPlaylists | ||
29 | |||
30 | const file = hlsPlaylist.files.find(f => f.resolution.id === resolution) | ||
31 | const videoName = basename(file.fileUrl) | ||
32 | |||
33 | const playlist = await command.get({ url: `${baseUrlPlaylist}/${removeFragmentedMP4Ext(videoName)}.m3u8`, token }) | ||
34 | |||
35 | const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist) | ||
36 | |||
37 | const length = parseInt(matches[1], 10) | ||
38 | const offset = parseInt(matches[2], 10) | ||
39 | const range = `${offset}-${offset + length - 1}` | ||
40 | |||
41 | const segmentBody = await command.getFragmentedSegment({ | ||
42 | url: `${baseUrlSegment}/${videoName}`, | ||
43 | expectedStatus: HttpStatusCode.PARTIAL_CONTENT_206, | ||
44 | range: `bytes=${range}`, | ||
45 | token | ||
46 | }) | ||
47 | |||
48 | const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, token }) | ||
49 | expect(sha256(segmentBody)).to.equal(shaBody[videoName][range], `Invalid sha256 result for ${videoName} range ${range}`) | ||
50 | } | ||
51 | |||
52 | // --------------------------------------------------------------------------- | ||
53 | |||
54 | async function checkLiveSegmentHash (options: { | ||
55 | server: PeerTubeServer | ||
56 | baseUrlSegment: string | ||
57 | videoUUID: string | ||
58 | segmentName: string | ||
59 | hlsPlaylist: VideoStreamingPlaylist | ||
60 | withRetry?: boolean | ||
61 | }) { | ||
62 | const { server, baseUrlSegment, videoUUID, segmentName, hlsPlaylist, withRetry = false } = options | ||
63 | const command = server.streamingPlaylists | ||
64 | |||
65 | const segmentBody = await command.getFragmentedSegment({ url: `${baseUrlSegment}/${videoUUID}/${segmentName}`, withRetry }) | ||
66 | const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry }) | ||
67 | |||
68 | expect(sha256(segmentBody)).to.equal(shaBody[segmentName]) | ||
69 | } | ||
70 | |||
71 | // --------------------------------------------------------------------------- | ||
72 | |||
73 | async function checkResolutionsInMasterPlaylist (options: { | ||
74 | server: PeerTubeServer | ||
75 | playlistUrl: string | ||
76 | resolutions: number[] | ||
77 | token?: string | ||
78 | transcoded?: boolean // default true | ||
79 | withRetry?: boolean // default false | ||
80 | }) { | ||
81 | const { server, playlistUrl, resolutions, token, withRetry = false, transcoded = true } = options | ||
82 | |||
83 | const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry }) | ||
84 | |||
85 | for (const resolution of resolutions) { | ||
86 | const base = '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution | ||
87 | |||
88 | if (resolution === VideoResolution.H_NOVIDEO) { | ||
89 | expect(masterPlaylist).to.match(new RegExp(`${base},CODECS="mp4a.40.2"`)) | ||
90 | } else if (transcoded) { | ||
91 | expect(masterPlaylist).to.match(new RegExp(`${base},(FRAME-RATE=\\d+,)?CODECS="avc1.64001f,mp4a.40.2"`)) | ||
92 | } else { | ||
93 | expect(masterPlaylist).to.match(new RegExp(`${base}`)) | ||
94 | } | ||
95 | } | ||
96 | |||
97 | const playlistsLength = masterPlaylist.split('\n').filter(line => line.startsWith('#EXT-X-STREAM-INF:BANDWIDTH=')) | ||
98 | expect(playlistsLength).to.have.lengthOf(resolutions.length) | ||
99 | } | ||
100 | |||
101 | async function completeCheckHlsPlaylist (options: { | ||
102 | servers: PeerTubeServer[] | ||
103 | videoUUID: string | ||
104 | hlsOnly: boolean | ||
105 | |||
106 | resolutions?: number[] | ||
107 | objectStorageBaseUrl?: string | ||
108 | }) { | ||
109 | const { videoUUID, hlsOnly, objectStorageBaseUrl } = options | ||
110 | |||
111 | const resolutions = options.resolutions ?? [ 240, 360, 480, 720 ] | ||
112 | |||
113 | for (const server of options.servers) { | ||
114 | const videoDetails = await server.videos.getWithToken({ id: videoUUID }) | ||
115 | const requiresAuth = videoDetails.privacy.id === VideoPrivacy.PRIVATE || videoDetails.privacy.id === VideoPrivacy.INTERNAL | ||
116 | |||
117 | const privatePath = requiresAuth | ||
118 | ? 'private/' | ||
119 | : '' | ||
120 | const token = requiresAuth | ||
121 | ? server.accessToken | ||
122 | : undefined | ||
123 | |||
124 | const baseUrl = `http://${videoDetails.account.host}` | ||
125 | |||
126 | expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) | ||
127 | |||
128 | const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) | ||
129 | expect(hlsPlaylist).to.not.be.undefined | ||
130 | |||
131 | const hlsFiles = hlsPlaylist.files | ||
132 | expect(hlsFiles).to.have.lengthOf(resolutions.length) | ||
133 | |||
134 | if (hlsOnly) expect(videoDetails.files).to.have.lengthOf(0) | ||
135 | else expect(videoDetails.files).to.have.lengthOf(resolutions.length) | ||
136 | |||
137 | // Check JSON files | ||
138 | for (const resolution of resolutions) { | ||
139 | const file = hlsFiles.find(f => f.resolution.id === resolution) | ||
140 | expect(file).to.not.be.undefined | ||
141 | |||
142 | if (file.resolution.id === VideoResolution.H_NOVIDEO) { | ||
143 | expect(file.resolution.label).to.equal('Audio') | ||
144 | } else { | ||
145 | expect(file.resolution.label).to.equal(resolution + 'p') | ||
146 | } | ||
147 | |||
148 | expect(file.magnetUri).to.have.lengthOf.above(2) | ||
149 | await checkWebTorrentWorks(file.magnetUri) | ||
150 | |||
151 | { | ||
152 | const nameReg = `${uuidRegex}-${file.resolution.id}` | ||
153 | |||
154 | expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}-hls.torrent`)) | ||
155 | |||
156 | if (objectStorageBaseUrl && requiresAuth) { | ||
157 | // eslint-disable-next-line max-len | ||
158 | expect(file.fileUrl).to.match(new RegExp(`${server.url}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`)) | ||
159 | } else if (objectStorageBaseUrl) { | ||
160 | expectStartWith(file.fileUrl, objectStorageBaseUrl) | ||
161 | } else { | ||
162 | expect(file.fileUrl).to.match( | ||
163 | new RegExp(`${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`) | ||
164 | ) | ||
165 | } | ||
166 | } | ||
167 | |||
168 | { | ||
169 | await Promise.all([ | ||
170 | makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }), | ||
171 | makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }), | ||
172 | makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }), | ||
173 | makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }), | ||
174 | |||
175 | makeRawRequest({ | ||
176 | url: file.fileDownloadUrl, | ||
177 | token, | ||
178 | expectedStatus: objectStorageBaseUrl | ||
179 | ? HttpStatusCode.FOUND_302 | ||
180 | : HttpStatusCode.OK_200 | ||
181 | }) | ||
182 | ]) | ||
183 | } | ||
184 | } | ||
185 | |||
186 | // Check master playlist | ||
187 | { | ||
188 | await checkResolutionsInMasterPlaylist({ server, token, playlistUrl: hlsPlaylist.playlistUrl, resolutions }) | ||
189 | |||
190 | const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl, token }) | ||
191 | |||
192 | let i = 0 | ||
193 | for (const resolution of resolutions) { | ||
194 | expect(masterPlaylist).to.contain(`${resolution}.m3u8`) | ||
195 | expect(masterPlaylist).to.contain(`${resolution}.m3u8`) | ||
196 | |||
197 | const url = 'http://' + videoDetails.account.host | ||
198 | await hlsInfohashExist(url, hlsPlaylist.playlistUrl, i) | ||
199 | |||
200 | i++ | ||
201 | } | ||
202 | } | ||
203 | |||
204 | // Check resolution playlists | ||
205 | { | ||
206 | for (const resolution of resolutions) { | ||
207 | const file = hlsFiles.find(f => f.resolution.id === resolution) | ||
208 | const playlistName = removeFragmentedMP4Ext(basename(file.fileUrl)) + '.m3u8' | ||
209 | |||
210 | let url: string | ||
211 | if (objectStorageBaseUrl && requiresAuth) { | ||
212 | url = `${baseUrl}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoUUID}/${playlistName}` | ||
213 | } else if (objectStorageBaseUrl) { | ||
214 | url = `${objectStorageBaseUrl}hls/${videoUUID}/${playlistName}` | ||
215 | } else { | ||
216 | url = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}/${playlistName}` | ||
217 | } | ||
218 | |||
219 | const subPlaylist = await server.streamingPlaylists.get({ url, token }) | ||
220 | |||
221 | expect(subPlaylist).to.match(new RegExp(`${uuidRegex}-${resolution}-fragmented.mp4`)) | ||
222 | expect(subPlaylist).to.contain(basename(file.fileUrl)) | ||
223 | } | ||
224 | } | ||
225 | |||
226 | { | ||
227 | let baseUrlAndPath: string | ||
228 | if (objectStorageBaseUrl && requiresAuth) { | ||
229 | baseUrlAndPath = `${baseUrl}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoUUID}` | ||
230 | } else if (objectStorageBaseUrl) { | ||
231 | baseUrlAndPath = `${objectStorageBaseUrl}hls/${videoUUID}` | ||
232 | } else { | ||
233 | baseUrlAndPath = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}` | ||
234 | } | ||
235 | |||
236 | for (const resolution of resolutions) { | ||
237 | await checkSegmentHash({ | ||
238 | server, | ||
239 | token, | ||
240 | baseUrlPlaylist: baseUrlAndPath, | ||
241 | baseUrlSegment: baseUrlAndPath, | ||
242 | resolution, | ||
243 | hlsPlaylist | ||
244 | }) | ||
245 | } | ||
246 | } | ||
247 | } | ||
248 | } | ||
249 | |||
250 | async function checkVideoFileTokenReinjection (options: { | ||
251 | server: PeerTubeServer | ||
252 | videoUUID: string | ||
253 | videoFileToken: string | ||
254 | resolutions: number[] | ||
255 | isLive: boolean | ||
256 | }) { | ||
257 | const { server, resolutions, videoFileToken, videoUUID, isLive } = options | ||
258 | |||
259 | const video = await server.videos.getWithToken({ id: videoUUID }) | ||
260 | const hls = video.streamingPlaylists[0] | ||
261 | |||
262 | const query = { videoFileToken, reinjectVideoFileToken: 'true' } | ||
263 | const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 }) | ||
264 | |||
265 | for (let i = 0; i < resolutions.length; i++) { | ||
266 | const resolution = resolutions[i] | ||
267 | |||
268 | const suffix = isLive | ||
269 | ? i | ||
270 | : `-${resolution}` | ||
271 | |||
272 | expect(text).to.contain(`${suffix}.m3u8?videoFileToken=${videoFileToken}&reinjectVideoFileToken=true`) | ||
273 | } | ||
274 | |||
275 | const resolutionPlaylists = extractResolutionPlaylistUrls(hls.playlistUrl, text) | ||
276 | expect(resolutionPlaylists).to.have.lengthOf(resolutions.length) | ||
277 | |||
278 | for (const url of resolutionPlaylists) { | ||
279 | const { text } = await makeRawRequest({ url, query, expectedStatus: HttpStatusCode.OK_200 }) | ||
280 | |||
281 | const extension = isLive | ||
282 | ? '.ts' | ||
283 | : '.mp4' | ||
284 | |||
285 | expect(text).to.contain(`${extension}?videoFileToken=${videoFileToken}`) | ||
286 | expect(text).not.to.contain(`reinjectVideoFileToken=true`) | ||
287 | } | ||
288 | } | ||
289 | |||
290 | function extractResolutionPlaylistUrls (masterPath: string, masterContent: string) { | ||
291 | return masterContent.match(/^([^.]+\.m3u8.*)/mg) | ||
292 | .map(filename => join(dirname(masterPath), filename)) | ||
293 | } | ||
294 | |||
295 | export { | ||
296 | checkSegmentHash, | ||
297 | checkLiveSegmentHash, | ||
298 | checkResolutionsInMasterPlaylist, | ||
299 | completeCheckHlsPlaylist, | ||
300 | extractResolutionPlaylistUrls, | ||
301 | checkVideoFileTokenReinjection | ||
302 | } | ||