aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-02-07 15:08:19 +0100
committerChocobozzz <chocobozzz@cpy.re>2019-02-11 09:13:02 +0100
commit4c280004ce62bf11ddb091854c28f1e1d54a54d6 (patch)
tree1899fff4ef18f8663a865997d5d06119b2149319 /server
parent6ec0b75beb9c8bcd84e178912319913b91830da2 (diff)
downloadPeerTube-4c280004ce62bf11ddb091854c28f1e1d54a54d6.tar.gz
PeerTube-4c280004ce62bf11ddb091854c28f1e1d54a54d6.tar.zst
PeerTube-4c280004ce62bf11ddb091854c28f1e1d54a54d6.zip
Use a single file instead of segments for HLS
Diffstat (limited to 'server')
-rw-r--r--server/helpers/ffmpeg-utils.ts12
-rw-r--r--server/helpers/requests.ts2
-rw-r--r--server/helpers/utils.ts1
-rw-r--r--server/lib/activitypub/actor.ts4
-rw-r--r--server/lib/hls.ts136
-rw-r--r--server/lib/video-transcoding.ts5
-rw-r--r--server/models/video/video-streaming-playlist.ts4
-rw-r--r--server/tests/api/redundancy/redundancy.ts22
-rw-r--r--server/tests/api/videos/video-hls.ts16
9 files changed, 132 insertions, 70 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 5ad8ed48e..133b1b03b 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -122,7 +122,9 @@ type TranscodeOptions = {
122 resolution: VideoResolution 122 resolution: VideoResolution
123 isPortraitMode?: boolean 123 isPortraitMode?: boolean
124 124
125 generateHlsPlaylist?: boolean 125 hlsPlaylist?: {
126 videoFilename: string
127 }
126} 128}
127 129
128function transcode (options: TranscodeOptions) { 130function transcode (options: TranscodeOptions) {
@@ -161,14 +163,16 @@ function transcode (options: TranscodeOptions) {
161 command = command.withFPS(fps) 163 command = command.withFPS(fps)
162 } 164 }
163 165
164 if (options.generateHlsPlaylist) { 166 if (options.hlsPlaylist) {
165 const segmentFilename = `${dirname(options.outputPath)}/${options.resolution}_%03d.ts` 167 const videoPath = `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
166 168
167 command = command.outputOption('-hls_time 4') 169 command = command.outputOption('-hls_time 4')
168 .outputOption('-hls_list_size 0') 170 .outputOption('-hls_list_size 0')
169 .outputOption('-hls_playlist_type vod') 171 .outputOption('-hls_playlist_type vod')
170 .outputOption('-hls_segment_filename ' + segmentFilename) 172 .outputOption('-hls_segment_filename ' + videoPath)
173 .outputOption('-hls_segment_type fmp4')
171 .outputOption('-f hls') 174 .outputOption('-f hls')
175 .outputOption('-hls_flags single_file')
172 } 176 }
173 177
174 command 178 command
diff --git a/server/helpers/requests.ts b/server/helpers/requests.ts
index 3fc776f1a..5c6dc5e19 100644
--- a/server/helpers/requests.ts
+++ b/server/helpers/requests.ts
@@ -7,7 +7,7 @@ import { join } from 'path'
7 7
8function doRequest <T> ( 8function doRequest <T> (
9 requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean } 9 requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean }
10): Bluebird<{ response: request.RequestResponse, body: any }> { 10): Bluebird<{ response: request.RequestResponse, body: T }> {
11 if (requestOptions.activityPub === true) { 11 if (requestOptions.activityPub === true) {
12 if (!Array.isArray(requestOptions.headers)) requestOptions.headers = {} 12 if (!Array.isArray(requestOptions.headers)) requestOptions.headers = {}
13 requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER 13 requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index 3c3406e38..cb0e823c5 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -7,7 +7,6 @@ import { join } from 'path'
7import { Instance as ParseTorrent } from 'parse-torrent' 7import { Instance as ParseTorrent } from 'parse-torrent'
8import { remove } from 'fs-extra' 8import { remove } from 'fs-extra'
9import * as memoizee from 'memoizee' 9import * as memoizee from 'memoizee'
10import { isArray } from './custom-validators/misc'
11 10
12function deleteFileAsync (path: string) { 11function deleteFileAsync (path: string) {
13 remove(path) 12 remove(path)
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts
index 8215840da..a3f379b76 100644
--- a/server/lib/activitypub/actor.ts
+++ b/server/lib/activitypub/actor.ts
@@ -355,10 +355,10 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
355 355
356 logger.info('Fetching remote actor %s.', actorUrl) 356 logger.info('Fetching remote actor %s.', actorUrl)
357 357
358 const requestResult = await doRequest(options) 358 const requestResult = await doRequest<ActivityPubActor>(options)
359 normalizeActor(requestResult.body) 359 normalizeActor(requestResult.body)
360 360
361 const actorJSON: ActivityPubActor = requestResult.body 361 const actorJSON = requestResult.body
362 if (isActorObjectValid(actorJSON) === false) { 362 if (isActorObjectValid(actorJSON) === false) {
363 logger.debug('Remote actor JSON is not valid.', { actorJSON }) 363 logger.debug('Remote actor JSON is not valid.', { actorJSON })
364 return { result: undefined, statusCode: requestResult.response.statusCode } 364 return { result: undefined, statusCode: requestResult.response.statusCode }
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
index 10db6c3c3..3575981f4 100644
--- a/server/lib/hls.ts
+++ b/server/lib/hls.ts
@@ -1,13 +1,14 @@
1import { VideoModel } from '../models/video/video' 1import { VideoModel } from '../models/video/video'
2import { basename, dirname, join } from 'path' 2import { basename, join, dirname } from 'path'
3import { HLS_PLAYLIST_DIRECTORY, CONFIG } from '../initializers' 3import { CONFIG, HLS_PLAYLIST_DIRECTORY } from '../initializers'
4import { outputJSON, pathExists, readdir, readFile, remove, writeFile, move } from 'fs-extra' 4import { close, ensureDir, move, open, outputJSON, pathExists, read, readFile, remove, writeFile } from 'fs-extra'
5import { getVideoFileSize } from '../helpers/ffmpeg-utils' 5import { getVideoFileSize } from '../helpers/ffmpeg-utils'
6import { sha256 } from '../helpers/core-utils' 6import { sha256 } from '../helpers/core-utils'
7import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 7import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
8import HLSDownloader from 'hlsdownloader'
9import { logger } from '../helpers/logger' 8import { logger } from '../helpers/logger'
10import { parse } from 'url' 9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
10import { generateRandomString } from '../helpers/utils'
11import { flatten, uniq } from 'lodash'
11 12
12async function updateMasterHLSPlaylist (video: VideoModel) { 13async function updateMasterHLSPlaylist (video: VideoModel) {
13 const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid) 14 const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
@@ -37,66 +38,119 @@ async function updateMasterHLSPlaylist (video: VideoModel) {
37} 38}
38 39
39async function updateSha256Segments (video: VideoModel) { 40async function updateSha256Segments (video: VideoModel) {
40 const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid) 41 const json: { [filename: string]: { [range: string]: string } } = {}
41 const files = await readdir(directory) 42
42 const json: { [filename: string]: string} = {} 43 const playlistDirectory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
44
45 // For all the resolutions available for this video
46 for (const file of video.VideoFiles) {
47 const rangeHashes: { [range: string]: string } = {}
48
49 const videoPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution))
50 const playlistPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
43 51
44 for (const file of files) { 52 // Maybe the playlist is not generated for this resolution yet
45 if (file.endsWith('.ts') === false) continue 53 if (!await pathExists(playlistPath)) continue
46 54
47 const buffer = await readFile(join(directory, file)) 55 const playlistContent = await readFile(playlistPath)
48 const filename = basename(file) 56 const ranges = getRangesFromPlaylist(playlistContent.toString())
49 57
50 json[filename] = sha256(buffer) 58 const fd = await open(videoPath, 'r')
59 for (const range of ranges) {
60 const buf = Buffer.alloc(range.length)
61 await read(fd, buf, 0, range.length, range.offset)
62
63 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
64 }
65 await close(fd)
66
67 const videoFilename = VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution)
68 json[videoFilename] = rangeHashes
51 } 69 }
52 70
53 const outputPath = join(directory, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) 71 const outputPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
54 await outputJSON(outputPath, json) 72 await outputJSON(outputPath, json)
55} 73}
56 74
57function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) { 75function getRangesFromPlaylist (playlistContent: string) {
58 let timer 76 const ranges: { offset: number, length: number }[] = []
77 const lines = playlistContent.split('\n')
78 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
59 79
60 logger.info('Importing HLS playlist %s', playlistUrl) 80 for (const line of lines) {
81 const captured = regex.exec(line)
61 82
62 const params = { 83 if (captured) {
63 playlistURL: playlistUrl, 84 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
64 destination: CONFIG.STORAGE.TMP_DIR 85 }
65 } 86 }
66 const downloader = new HLSDownloader(params)
67
68 const hlsDestinationDir = join(CONFIG.STORAGE.TMP_DIR, dirname(parse(playlistUrl).pathname))
69 87
70 return new Promise<string>(async (res, rej) => { 88 return ranges
71 downloader.startDownload(err => { 89}
72 clearTimeout(timer)
73 90
74 if (err) { 91function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) {
75 deleteTmpDirectory(hlsDestinationDir) 92 let timer
76 93
77 return rej(err) 94 logger.info('Importing HLS playlist %s', playlistUrl)
78 }
79 95
80 move(hlsDestinationDir, destinationDir, { overwrite: true }) 96 return new Promise<string>(async (res, rej) => {
81 .then(() => res()) 97 const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
82 .catch(err => {
83 deleteTmpDirectory(hlsDestinationDir)
84 98
85 return rej(err) 99 await ensureDir(tmpDirectory)
86 })
87 })
88 100
89 timer = setTimeout(() => { 101 timer = setTimeout(() => {
90 deleteTmpDirectory(hlsDestinationDir) 102 deleteTmpDirectory(tmpDirectory)
91 103
92 return rej(new Error('HLS download timeout.')) 104 return rej(new Error('HLS download timeout.'))
93 }, timeout) 105 }, timeout)
94 106
95 function deleteTmpDirectory (directory: string) { 107 try {
96 remove(directory) 108 // Fetch master playlist
97 .catch(err => logger.error('Cannot delete path on HLS download error.', { err })) 109 const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
110
111 const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
112 const fileUrls = uniq(flatten(await Promise.all(subRequests)))
113
114 logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
115
116 for (const fileUrl of fileUrls) {
117 const destPath = join(tmpDirectory, basename(fileUrl))
118
119 await doRequestAndSaveToFile({ uri: fileUrl }, destPath)
120 }
121
122 clearTimeout(timer)
123
124 await move(tmpDirectory, destinationDir, { overwrite: true })
125
126 return res()
127 } catch (err) {
128 deleteTmpDirectory(tmpDirectory)
129
130 return rej(err)
98 } 131 }
99 }) 132 })
133
134 function deleteTmpDirectory (directory: string) {
135 remove(directory)
136 .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
137 }
138
139 async function fetchUniqUrls (playlistUrl: string) {
140 const { body } = await doRequest<string>({ uri: playlistUrl })
141
142 if (!body) return []
143
144 const urls = body.split('\n')
145 .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
146 .map(url => {
147 if (url.startsWith('http://') || url.startsWith('https://')) return url
148
149 return `${dirname(playlistUrl)}/${url}`
150 })
151
152 return uniq(urls)
153 }
100} 154}
101 155
102// --------------------------------------------------------------------------- 156// ---------------------------------------------------------------------------
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
index 608badfef..086b860a2 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/video-transcoding.ts
@@ -100,7 +100,10 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti
100 outputPath, 100 outputPath,
101 resolution, 101 resolution,
102 isPortraitMode, 102 isPortraitMode,
103 generateHlsPlaylist: true 103
104 hlsPlaylist: {
105 videoFilename: VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, resolution)
106 }
104 } 107 }
105 108
106 await transcode(transcodeOptions) 109 await transcode(transcodeOptions)
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index bce537781..bf6f7b0c4 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -125,6 +125,10 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
125 return 'segments-sha256.json' 125 return 'segments-sha256.json'
126 } 126 }
127 127
128 static getHlsVideoName (uuid: string, resolution: number) {
129 return `${uuid}-${resolution}-fragmented.mp4`
130 }
131
128 static getHlsMasterPlaylistStaticPath (videoUUID: string) { 132 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
129 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename()) 133 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
130 } 134 }
diff --git a/server/tests/api/redundancy/redundancy.ts b/server/tests/api/redundancy/redundancy.ts
index 5b99309fb..778611fff 100644
--- a/server/tests/api/redundancy/redundancy.ts
+++ b/server/tests/api/redundancy/redundancy.ts
@@ -17,7 +17,7 @@ import {
17 viewVideo, 17 viewVideo,
18 wait, 18 wait,
19 waitUntilLog, 19 waitUntilLog,
20 checkVideoFilesWereRemoved, removeVideo, getVideoWithToken, reRunServer 20 checkVideoFilesWereRemoved, removeVideo, getVideoWithToken, reRunServer, checkSegmentHash
21} from '../../../../shared/utils' 21} from '../../../../shared/utils'
22import { waitJobs } from '../../../../shared/utils/server/jobs' 22import { waitJobs } from '../../../../shared/utils/server/jobs'
23 23
@@ -178,20 +178,24 @@ async function check1PlaylistRedundancies (videoUUID?: string) {
178 expect(redundancy.baseUrl).to.equal(servers[0].url + '/static/redundancy/hls/' + videoUUID) 178 expect(redundancy.baseUrl).to.equal(servers[0].url + '/static/redundancy/hls/' + videoUUID)
179 } 179 }
180 180
181 await makeGetRequest({ 181 const baseUrlPlaylist = servers[1].url + '/static/playlists/hls'
182 url: servers[0].url, 182 const baseUrlSegment = servers[0].url + '/static/redundancy/hls'
183 statusCodeExpected: 200, 183
184 path: `/static/redundancy/hls/${videoUUID}/360_000.ts`, 184 const res = await getVideo(servers[0].url, videoUUID)
185 contentType: null 185 const hlsPlaylist = (res.body as VideoDetails).streamingPlaylists[0]
186 }) 186
187 for (const resolution of [ 240, 360, 480, 720 ]) {
188 await checkSegmentHash(baseUrlPlaylist, baseUrlSegment, videoUUID, resolution, hlsPlaylist)
189 }
187 190
188 for (const directory of [ 'test1/redundancy/hls', 'test2/playlists/hls' ]) { 191 for (const directory of [ 'test1/redundancy/hls', 'test2/playlists/hls' ]) {
189 const files = await readdir(join(root(), directory, videoUUID)) 192 const files = await readdir(join(root(), directory, videoUUID))
190 expect(files).to.have.length.at.least(4) 193 expect(files).to.have.length.at.least(4)
191 194
192 for (const resolution of [ 240, 360, 480, 720 ]) { 195 for (const resolution of [ 240, 360, 480, 720 ]) {
193 expect(files.find(f => f === `${resolution}_000.ts`)).to.not.be.undefined 196 const filename = `${videoUUID}-${resolution}-fragmented.mp4`
194 expect(files.find(f => f === `${resolution}_001.ts`)).to.not.be.undefined 197
198 expect(files.find(f => f === filename)).to.not.be.undefined
195 } 199 }
196 } 200 }
197} 201}
diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts
index 71d863b12..a1214bad1 100644
--- a/server/tests/api/videos/video-hls.ts
+++ b/server/tests/api/videos/video-hls.ts
@@ -4,13 +4,12 @@ import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { 5import {
6 checkDirectoryIsEmpty, 6 checkDirectoryIsEmpty,
7 checkSegmentHash,
7 checkTmpIsEmpty, 8 checkTmpIsEmpty,
8 doubleFollow, 9 doubleFollow,
9 flushAndRunMultipleServers, 10 flushAndRunMultipleServers,
10 flushTests, 11 flushTests,
11 getPlaylist, 12 getPlaylist,
12 getSegment,
13 getSegmentSha256,
14 getVideo, 13 getVideo,
15 killallServers, 14 killallServers,
16 removeVideo, 15 removeVideo,
@@ -22,7 +21,6 @@ import {
22} from '../../../../shared/utils' 21} from '../../../../shared/utils'
23import { VideoDetails } from '../../../../shared/models/videos' 22import { VideoDetails } from '../../../../shared/models/videos'
24import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type' 23import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
25import { sha256 } from '../../../helpers/core-utils'
26import { join } from 'path' 24import { join } from 'path'
27 25
28const expect = chai.expect 26const expect = chai.expect
@@ -56,19 +54,15 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
56 const res2 = await getPlaylist(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}.m3u8`) 54 const res2 = await getPlaylist(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}.m3u8`)
57 55
58 const subPlaylist = res2.text 56 const subPlaylist = res2.text
59 expect(subPlaylist).to.contain(resolution + '_000.ts') 57 expect(subPlaylist).to.contain(`${videoUUID}-${resolution}-fragmented.mp4`)
60 } 58 }
61 } 59 }
62 60
63 { 61 {
64 for (const resolution of resolutions) { 62 const baseUrl = 'http://localhost:9001/static/playlists/hls'
65
66 const res2 = await getSegment(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}_000.ts`)
67 63
68 const resSha = await getSegmentSha256(hlsPlaylist.segmentsSha256Url) 64 for (const resolution of resolutions) {
69 65 await checkSegmentHash(baseUrl, baseUrl, videoUUID, resolution, hlsPlaylist)
70 const sha256Server = resSha.body[ resolution + '_000.ts' ]
71 expect(sha256(res2.body)).to.equal(sha256Server)
72 } 66 }
73 } 67 }
74 } 68 }