diff options
Diffstat (limited to 'server/lib/hls.ts')
-rw-r--r-- | server/lib/hls.ts | 184 |
1 files changed, 184 insertions, 0 deletions
diff --git a/server/lib/hls.ts b/server/lib/hls.ts new file mode 100644 index 000000000..98da4dcd8 --- /dev/null +++ b/server/lib/hls.ts | |||
@@ -0,0 +1,184 @@ | |||
1 | import { VideoModel } from '../models/video/video' | ||
2 | import { basename, dirname, join } from 'path' | ||
3 | import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION } from '../initializers/constants' | ||
4 | import { close, ensureDir, move, open, outputJSON, pathExists, read, readFile, remove, writeFile } from 'fs-extra' | ||
5 | import { getVideoFileSize } from '../helpers/ffmpeg-utils' | ||
6 | import { sha256 } from '../helpers/core-utils' | ||
7 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' | ||
8 | import { logger } from '../helpers/logger' | ||
9 | import { doRequest, doRequestAndSaveToFile } from '../helpers/requests' | ||
10 | import { generateRandomString } from '../helpers/utils' | ||
11 | import { flatten, uniq } from 'lodash' | ||
12 | import { VideoFileModel } from '../models/video/video-file' | ||
13 | import { CONFIG } from '../initializers/config' | ||
14 | import { sequelizeTypescript } from '../initializers/database' | ||
15 | |||
16 | async function updateStreamingPlaylistsInfohashesIfNeeded () { | ||
17 | const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion() | ||
18 | |||
19 | // Use separate SQL queries, because we could have many videos to update | ||
20 | for (const playlist of playlistsToUpdate) { | ||
21 | await sequelizeTypescript.transaction(async t => { | ||
22 | const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlist.id, t) | ||
23 | |||
24 | playlist.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlist.playlistUrl, videoFiles) | ||
25 | playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION | ||
26 | await playlist.save({ transaction: t }) | ||
27 | }) | ||
28 | } | ||
29 | } | ||
30 | |||
31 | async function updateMasterHLSPlaylist (video: VideoModel) { | ||
32 | const directory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) | ||
33 | const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ] | ||
34 | const masterPlaylistPath = join(directory, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename()) | ||
35 | |||
36 | for (const file of video.VideoFiles) { | ||
37 | // If we did not generated a playlist for this resolution, skip | ||
38 | const filePlaylistPath = join(directory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution)) | ||
39 | if (await pathExists(filePlaylistPath) === false) continue | ||
40 | |||
41 | const videoFilePath = video.getVideoFilePath(file) | ||
42 | |||
43 | const size = await getVideoFileSize(videoFilePath) | ||
44 | |||
45 | const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file) | ||
46 | const resolution = `RESOLUTION=${size.width}x${size.height}` | ||
47 | |||
48 | let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}` | ||
49 | if (file.fps) line += ',FRAME-RATE=' + file.fps | ||
50 | |||
51 | masterPlaylists.push(line) | ||
52 | masterPlaylists.push(VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution)) | ||
53 | } | ||
54 | |||
55 | await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n') | ||
56 | } | ||
57 | |||
58 | async function updateSha256Segments (video: VideoModel) { | ||
59 | const json: { [filename: string]: { [range: string]: string } } = {} | ||
60 | |||
61 | const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) | ||
62 | |||
63 | // For all the resolutions available for this video | ||
64 | for (const file of video.VideoFiles) { | ||
65 | const rangeHashes: { [range: string]: string } = {} | ||
66 | |||
67 | const videoPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution)) | ||
68 | const playlistPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution)) | ||
69 | |||
70 | // Maybe the playlist is not generated for this resolution yet | ||
71 | if (!await pathExists(playlistPath)) continue | ||
72 | |||
73 | const playlistContent = await readFile(playlistPath) | ||
74 | const ranges = getRangesFromPlaylist(playlistContent.toString()) | ||
75 | |||
76 | const fd = await open(videoPath, 'r') | ||
77 | for (const range of ranges) { | ||
78 | const buf = Buffer.alloc(range.length) | ||
79 | await read(fd, buf, 0, range.length, range.offset) | ||
80 | |||
81 | rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf) | ||
82 | } | ||
83 | await close(fd) | ||
84 | |||
85 | const videoFilename = VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution) | ||
86 | json[videoFilename] = rangeHashes | ||
87 | } | ||
88 | |||
89 | const outputPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) | ||
90 | await outputJSON(outputPath, json) | ||
91 | } | ||
92 | |||
93 | function getRangesFromPlaylist (playlistContent: string) { | ||
94 | const ranges: { offset: number, length: number }[] = [] | ||
95 | const lines = playlistContent.split('\n') | ||
96 | const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/ | ||
97 | |||
98 | for (const line of lines) { | ||
99 | const captured = regex.exec(line) | ||
100 | |||
101 | if (captured) { | ||
102 | ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) }) | ||
103 | } | ||
104 | } | ||
105 | |||
106 | return ranges | ||
107 | } | ||
108 | |||
109 | function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) { | ||
110 | let timer | ||
111 | |||
112 | logger.info('Importing HLS playlist %s', playlistUrl) | ||
113 | |||
114 | return new Promise<string>(async (res, rej) => { | ||
115 | const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10)) | ||
116 | |||
117 | await ensureDir(tmpDirectory) | ||
118 | |||
119 | timer = setTimeout(() => { | ||
120 | deleteTmpDirectory(tmpDirectory) | ||
121 | |||
122 | return rej(new Error('HLS download timeout.')) | ||
123 | }, timeout) | ||
124 | |||
125 | try { | ||
126 | // Fetch master playlist | ||
127 | const subPlaylistUrls = await fetchUniqUrls(playlistUrl) | ||
128 | |||
129 | const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u)) | ||
130 | const fileUrls = uniq(flatten(await Promise.all(subRequests))) | ||
131 | |||
132 | logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls }) | ||
133 | |||
134 | for (const fileUrl of fileUrls) { | ||
135 | const destPath = join(tmpDirectory, basename(fileUrl)) | ||
136 | |||
137 | const bodyKBLimit = 10 * 1000 * 1000 // 10GB | ||
138 | await doRequestAndSaveToFile({ uri: fileUrl }, destPath, bodyKBLimit) | ||
139 | } | ||
140 | |||
141 | clearTimeout(timer) | ||
142 | |||
143 | await move(tmpDirectory, destinationDir, { overwrite: true }) | ||
144 | |||
145 | return res() | ||
146 | } catch (err) { | ||
147 | deleteTmpDirectory(tmpDirectory) | ||
148 | |||
149 | return rej(err) | ||
150 | } | ||
151 | }) | ||
152 | |||
153 | function deleteTmpDirectory (directory: string) { | ||
154 | remove(directory) | ||
155 | .catch(err => logger.error('Cannot delete path on HLS download error.', { err })) | ||
156 | } | ||
157 | |||
158 | async function fetchUniqUrls (playlistUrl: string) { | ||
159 | const { body } = await doRequest<string>({ uri: playlistUrl }) | ||
160 | |||
161 | if (!body) return [] | ||
162 | |||
163 | const urls = body.split('\n') | ||
164 | .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4')) | ||
165 | .map(url => { | ||
166 | if (url.startsWith('http://') || url.startsWith('https://')) return url | ||
167 | |||
168 | return `${dirname(playlistUrl)}/${url}` | ||
169 | }) | ||
170 | |||
171 | return uniq(urls) | ||
172 | } | ||
173 | } | ||
174 | |||
175 | // --------------------------------------------------------------------------- | ||
176 | |||
177 | export { | ||
178 | updateMasterHLSPlaylist, | ||
179 | updateSha256Segments, | ||
180 | downloadPlaylistSegments, | ||
181 | updateStreamingPlaylistsInfohashesIfNeeded | ||
182 | } | ||
183 | |||
184 | // --------------------------------------------------------------------------- | ||