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