hls.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import { uniqify, uuidRegex } from '@peertube/peertube-core-utils'
  2. import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
  3. import { FileStorage } from '@peertube/peertube-models'
  4. import { sha256 } from '@peertube/peertube-node-utils'
  5. import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
  6. import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm'
  7. import { open, readFile, stat, writeFile } from 'fs/promises'
  8. import flatten from 'lodash-es/flatten.js'
  9. import PQueue from 'p-queue'
  10. import { basename, dirname, join } from 'path'
  11. import { getAudioStreamCodec, getVideoStreamCodec } from '../helpers/ffmpeg/index.js'
  12. import { logger, loggerTagsFactory } from '../helpers/logger.js'
  13. import { doRequest, doRequestAndSaveToFile } from '../helpers/requests.js'
  14. import { generateRandomString } from '../helpers/utils.js'
  15. import { CONFIG } from '../initializers/config.js'
  16. import { P2P_MEDIA_LOADER_PEER_VERSION, REQUEST_TIMEOUTS } from '../initializers/constants.js'
  17. import { sequelizeTypescript } from '../initializers/database.js'
  18. import { VideoFileModel } from '../models/video/video-file.js'
  19. import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
  20. import { storeHLSFileFromFilename } from './object-storage/index.js'
  21. import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths.js'
  22. import { VideoPathManager } from './video-path-manager.js'
  23. const lTags = loggerTagsFactory('hls')
  24. async function updateStreamingPlaylistsInfohashesIfNeeded () {
  25. const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
  26. // Use separate SQL queries, because we could have many videos to update
  27. for (const playlist of playlistsToUpdate) {
  28. await sequelizeTypescript.transaction(async t => {
  29. const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlist.id, t)
  30. playlist.assignP2PMediaLoaderInfoHashes(playlist.Video, videoFiles)
  31. playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
  32. await playlist.save({ transaction: t })
  33. })
  34. }
  35. }
  36. async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
  37. try {
  38. let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
  39. playlistWithFiles = await updateSha256VODSegments(video, playlist)
  40. // Refresh playlist, operations can take some time
  41. playlistWithFiles = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlist.id)
  42. playlistWithFiles.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
  43. await playlistWithFiles.save()
  44. video.setHLSPlaylist(playlistWithFiles)
  45. } catch (err) {
  46. logger.warn('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err })
  47. }
  48. }
  49. // ---------------------------------------------------------------------------
  50. // Avoid concurrency issues when updating streaming playlist files
  51. const playlistFilesQueue = new PQueue({ concurrency: 1 })
  52. function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
  53. return playlistFilesQueue.add(async () => {
  54. const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
  55. const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
  56. for (const file of playlist.VideoFiles) {
  57. const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
  58. await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
  59. const size = await getVideoStreamDimensionsInfo(videoFilePath)
  60. const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
  61. const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
  62. let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
  63. if (file.fps) line += ',FRAME-RATE=' + file.fps
  64. const codecs = await Promise.all([
  65. getVideoStreamCodec(videoFilePath),
  66. getAudioStreamCodec(videoFilePath)
  67. ])
  68. line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
  69. masterPlaylists.push(line)
  70. masterPlaylists.push(playlistFilename)
  71. })
  72. }
  73. if (playlist.playlistFilename) {
  74. await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
  75. }
  76. playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
  77. const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
  78. await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
  79. logger.info('Updating %s master playlist file of video %s', masterPlaylistPath, video.uuid, lTags(video.uuid))
  80. if (playlist.storage === FileStorage.OBJECT_STORAGE) {
  81. playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename)
  82. await remove(masterPlaylistPath)
  83. }
  84. return playlist.save()
  85. }, { throwOnTimeout: true })
  86. }
  87. // ---------------------------------------------------------------------------
  88. function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
  89. return playlistFilesQueue.add(async () => {
  90. const json: { [filename: string]: { [range: string]: string } } = {}
  91. const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
  92. // For all the resolutions available for this video
  93. for (const file of playlist.VideoFiles) {
  94. const rangeHashes: { [range: string]: string } = {}
  95. const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
  96. await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
  97. return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
  98. const playlistContent = await readFile(resolutionPlaylistPath)
  99. const ranges = getRangesFromPlaylist(playlistContent.toString())
  100. const fd = await open(videoPath, 'r')
  101. for (const range of ranges) {
  102. const buf = Buffer.alloc(range.length)
  103. await fd.read(buf, 0, range.length, range.offset)
  104. rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
  105. }
  106. await fd.close()
  107. const videoFilename = file.filename
  108. json[videoFilename] = rangeHashes
  109. })
  110. })
  111. }
  112. if (playlist.segmentsSha256Filename) {
  113. await video.removeStreamingPlaylistFile(playlist, playlist.segmentsSha256Filename)
  114. }
  115. playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
  116. const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
  117. await outputJSON(outputPath, json)
  118. if (playlist.storage === FileStorage.OBJECT_STORAGE) {
  119. playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlist, playlist.segmentsSha256Filename)
  120. await remove(outputPath)
  121. }
  122. return playlist.save()
  123. }, { throwOnTimeout: true })
  124. }
  125. // ---------------------------------------------------------------------------
  126. async function buildSha256Segment (segmentPath: string) {
  127. const buf = await readFile(segmentPath)
  128. return sha256(buf)
  129. }
  130. function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
  131. let timer
  132. let remainingBodyKBLimit = bodyKBLimit
  133. logger.info('Importing HLS playlist %s', playlistUrl)
  134. return new Promise<void>(async (res, rej) => {
  135. const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
  136. await ensureDir(tmpDirectory)
  137. timer = setTimeout(() => {
  138. deleteTmpDirectory(tmpDirectory)
  139. return rej(new Error('HLS download timeout.'))
  140. }, timeout)
  141. try {
  142. // Fetch master playlist
  143. const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
  144. const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
  145. const fileUrls = uniqify(flatten(await Promise.all(subRequests)))
  146. logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
  147. for (const fileUrl of fileUrls) {
  148. const destPath = join(tmpDirectory, basename(fileUrl))
  149. await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
  150. const { size } = await stat(destPath)
  151. remainingBodyKBLimit -= (size / 1000)
  152. logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit))
  153. }
  154. clearTimeout(timer)
  155. await move(tmpDirectory, destinationDir, { overwrite: true })
  156. return res()
  157. } catch (err) {
  158. deleteTmpDirectory(tmpDirectory)
  159. return rej(err)
  160. }
  161. })
  162. function deleteTmpDirectory (directory: string) {
  163. remove(directory)
  164. .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
  165. }
  166. async function fetchUniqUrls (playlistUrl: string) {
  167. const { body } = await doRequest(playlistUrl)
  168. if (!body) return []
  169. const urls = body.split('\n')
  170. .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
  171. .map(url => {
  172. if (url.startsWith('http://') || url.startsWith('https://')) return url
  173. return `${dirname(playlistUrl)}/${url}`
  174. })
  175. return uniqify(urls)
  176. }
  177. }
  178. // ---------------------------------------------------------------------------
  179. async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) {
  180. const content = await readFile(playlistPath, 'utf8')
  181. const newContent = content.replace(new RegExp(`${uuidRegex}-\\d+-fragmented.mp4`, 'g'), newVideoFilename)
  182. await writeFile(playlistPath, newContent, 'utf8')
  183. }
  184. // ---------------------------------------------------------------------------
  185. function injectQueryToPlaylistUrls (content: string, queryString: string) {
  186. return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
  187. }
  188. // ---------------------------------------------------------------------------
  189. export {
  190. updateMasterHLSPlaylist,
  191. updateSha256VODSegments,
  192. buildSha256Segment,
  193. downloadPlaylistSegments,
  194. updateStreamingPlaylistsInfohashesIfNeeded,
  195. updatePlaylistAfterFileChange,
  196. injectQueryToPlaylistUrls,
  197. renameVideoFileInPlaylist
  198. }
  199. // ---------------------------------------------------------------------------
  200. function getRangesFromPlaylist (playlistContent: string) {
  201. const ranges: { offset: number, length: number }[] = []
  202. const lines = playlistContent.split('\n')
  203. const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
  204. for (const line of lines) {
  205. const captured = regex.exec(line)
  206. if (captured) {
  207. ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
  208. }
  209. }
  210. return ranges
  211. }