]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/hls.ts
Add test on AP hooks
[github/Chocobozzz/PeerTube.git] / server / lib / hls.ts
CommitLineData
0305db28 1import { close, ensureDir, move, open, outputJSON, read, readFile, remove, stat, writeFile } from 'fs-extra'
690bb8f9 2import { flatten } from 'lodash'
1bb4c9ab 3import PQueue from 'p-queue'
daf6e480 4import { basename, dirname, join } from 'path'
1bb4c9ab 5import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models'
690bb8f9 6import { uniqify } from '@shared/core-utils'
f304a158 7import { sha256 } from '@shared/extra-utils'
a2caee9f 8import { VideoStorage } from '@shared/models'
c729caf6 9import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
09209296 10import { logger } from '../helpers/logger'
4c280004
C
11import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
12import { generateRandomString } from '../helpers/utils'
6dd9de95 13import { CONFIG } from '../initializers/config'
7b0c61e7 14import { P2P_MEDIA_LOADER_PEER_VERSION, REQUEST_TIMEOUTS } from '../initializers/constants'
74dc3bca 15import { sequelizeTypescript } from '../initializers/database'
daf6e480
C
16import { VideoFileModel } from '../models/video/video-file'
17import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
cfd57d2c 18import { storeHLSFileFromFilename } from './object-storage'
1bb4c9ab 19import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths'
0305db28 20import { VideoPathManager } from './video-path-manager'
ae9bbed4
C
21
22async function updateStreamingPlaylistsInfohashesIfNeeded () {
23 const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
24
25 // Use separate SQL queries, because we could have many videos to update
26 for (const playlist of playlistsToUpdate) {
27 await sequelizeTypescript.transaction(async t => {
28 const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlist.id, t)
29
764b1a14 30 playlist.assignP2PMediaLoaderInfoHashes(playlist.Video, videoFiles)
0e9c48c2 31 playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
764b1a14 32
ae9bbed4
C
33 await playlist.save({ transaction: t })
34 })
35 }
36}
09209296 37
1bb4c9ab 38async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
51335c72
C
39 try {
40 let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
41 playlistWithFiles = await updateSha256VODSegments(video, playlist)
42
43 // Refresh playlist, operations can take some time
44 playlistWithFiles = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlist.id)
45 playlistWithFiles.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
46 await playlistWithFiles.save()
47
48 video.setHLSPlaylist(playlistWithFiles)
49 } catch (err) {
50 logger.info('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err })
51 }
1bb4c9ab 52}
09209296 53
1bb4c9ab 54// ---------------------------------------------------------------------------
09209296 55
1bb4c9ab
C
56// Avoid concurrency issues when updating streaming playlist files
57const playlistFilesQueue = new PQueue({ concurrency: 1 })
09209296 58
1bb4c9ab
C
59function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
60 return playlistFilesQueue.add(async () => {
61 const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
d7b1c7b4 62
1bb4c9ab 63 const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
52201311 64
1bb4c9ab
C
65 for (const file of playlist.VideoFiles) {
66 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
67
68 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
69 const size = await getVideoStreamDimensionsInfo(videoFilePath)
70
71 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
72 const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
73
74 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
75 if (file.fps) line += ',FRAME-RATE=' + file.fps
76
77 const codecs = await Promise.all([
78 getVideoStreamCodec(videoFilePath),
79 getAudioStreamCodec(videoFilePath)
80 ])
09209296 81
1bb4c9ab
C
82 line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
83
84 masterPlaylists.push(line)
85 masterPlaylists.push(playlistFilename)
86 })
87 }
88
89 if (playlist.playlistFilename) {
90 await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
91 }
92 playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
93
94 const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
a2caee9f
C
95 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
96
97 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
cfd57d2c 98 playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename)
1bb4c9ab 99 await remove(masterPlaylistPath)
a2caee9f 100 }
1bb4c9ab
C
101
102 return playlist.save()
0305db28 103 })
09209296
C
104}
105
1bb4c9ab
C
106// ---------------------------------------------------------------------------
107
b37d80e3 108function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
1bb4c9ab
C
109 return playlistFilesQueue.add(async () => {
110 const json: { [filename: string]: { [range: string]: string } } = {}
4c280004 111
1bb4c9ab 112 const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
4c280004 113
1bb4c9ab
C
114 // For all the resolutions available for this video
115 for (const file of playlist.VideoFiles) {
116 const rangeHashes: { [range: string]: string } = {}
117 const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
09209296 118
1bb4c9ab 119 await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
09209296 120
1bb4c9ab
C
121 return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
122 const playlistContent = await readFile(resolutionPlaylistPath)
123 const ranges = getRangesFromPlaylist(playlistContent.toString())
09209296 124
1bb4c9ab
C
125 const fd = await open(videoPath, 'r')
126 for (const range of ranges) {
127 const buf = Buffer.alloc(range.length)
128 await read(fd, buf, 0, range.length, range.offset)
4c280004 129
1bb4c9ab
C
130 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
131 }
132 await close(fd)
133
134 const videoFilename = file.filename
135 json[videoFilename] = rangeHashes
136 })
0305db28 137 })
1bb4c9ab 138 }
09209296 139
1bb4c9ab
C
140 if (playlist.segmentsSha256Filename) {
141 await video.removeStreamingPlaylistFile(playlist, playlist.segmentsSha256Filename)
142 }
143 playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
a2caee9f 144
1bb4c9ab
C
145 const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
146 await outputJSON(outputPath, json)
147
148 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
cfd57d2c 149 playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlist, playlist.segmentsSha256Filename)
1bb4c9ab
C
150 await remove(outputPath)
151 }
152
153 return playlist.save()
154 })
09209296
C
155}
156
1bb4c9ab
C
157// ---------------------------------------------------------------------------
158
c6c0fa6c
C
159async function buildSha256Segment (segmentPath: string) {
160 const buf = await readFile(segmentPath)
161 return sha256(buf)
162}
163
18998c45 164function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
4c280004 165 let timer
18998c45 166 let remainingBodyKBLimit = bodyKBLimit
09209296 167
4c280004 168 logger.info('Importing HLS playlist %s', playlistUrl)
09209296 169
ba5a8d89 170 return new Promise<void>(async (res, rej) => {
4c280004 171 const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
09209296 172
4c280004 173 await ensureDir(tmpDirectory)
09209296
C
174
175 timer = setTimeout(() => {
4c280004 176 deleteTmpDirectory(tmpDirectory)
09209296
C
177
178 return rej(new Error('HLS download timeout.'))
179 }, timeout)
180
4c280004
C
181 try {
182 // Fetch master playlist
183 const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
184
185 const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
690bb8f9 186 const fileUrls = uniqify(flatten(await Promise.all(subRequests)))
4c280004
C
187
188 logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
189
190 for (const fileUrl of fileUrls) {
191 const destPath = join(tmpDirectory, basename(fileUrl))
192
a5ee023c 193 await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
18998c45
C
194
195 const { size } = await stat(destPath)
196 remainingBodyKBLimit -= (size / 1000)
197
198 logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit))
4c280004
C
199 }
200
201 clearTimeout(timer)
202
203 await move(tmpDirectory, destinationDir, { overwrite: true })
204
205 return res()
206 } catch (err) {
207 deleteTmpDirectory(tmpDirectory)
208
209 return rej(err)
09209296
C
210 }
211 })
4c280004
C
212
213 function deleteTmpDirectory (directory: string) {
214 remove(directory)
215 .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
216 }
217
218 async function fetchUniqUrls (playlistUrl: string) {
db4b15f2 219 const { body } = await doRequest(playlistUrl)
4c280004
C
220
221 if (!body) return []
222
223 const urls = body.split('\n')
224 .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
225 .map(url => {
226 if (url.startsWith('http://') || url.startsWith('https://')) return url
227
228 return `${dirname(playlistUrl)}/${url}`
229 })
230
690bb8f9 231 return uniqify(urls)
4c280004 232 }
09209296
C
233}
234
235// ---------------------------------------------------------------------------
236
71e3e879
C
237function injectQueryToPlaylistUrls (content: string, queryString: string) {
238 return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
239}
240
241// ---------------------------------------------------------------------------
242
09209296
C
243export {
244 updateMasterHLSPlaylist,
c6c0fa6c
C
245 updateSha256VODSegments,
246 buildSha256Segment,
ae9bbed4 247 downloadPlaylistSegments,
1bb4c9ab 248 updateStreamingPlaylistsInfohashesIfNeeded,
71e3e879
C
249 updatePlaylistAfterFileChange,
250 injectQueryToPlaylistUrls
09209296
C
251}
252
253// ---------------------------------------------------------------------------
b5b68755
C
254
255function getRangesFromPlaylist (playlistContent: string) {
256 const ranges: { offset: number, length: number }[] = []
257 const lines = playlistContent.split('\n')
258 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
259
260 for (const line of lines) {
261 const captured = regex.exec(line)
262
263 if (captured) {
264 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
265 }
266 }
267
268 return ranges
269}