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