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