aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--config/test.yaml2
-rw-r--r--server/lib/client-html.ts3
-rw-r--r--server/lib/job-queue/handlers/video-file.ts7
-rw-r--r--server/lib/video-transcoding.ts130
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/video/video-format-utils.ts295
-rw-r--r--server/models/video/video.ts419
7 files changed, 455 insertions, 403 deletions
diff --git a/config/test.yaml b/config/test.yaml
index 16113211e..d3e0e49ac 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -32,7 +32,7 @@ redundancy:
32 - 32 -
33 size: '10MB' 33 size: '10MB'
34 strategy: 'recently-added' 34 strategy: 'recently-added'
35 minViews: 10 35 minViews: 1
36 36
37cache: 37cache:
38 previews: 38 previews:
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index a69e09c32..b1088c096 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -8,6 +8,7 @@ import { VideoModel } from '../models/video/video'
8import * as validator from 'validator' 8import * as validator from 'validator'
9import { VideoPrivacy } from '../../shared/models/videos' 9import { VideoPrivacy } from '../../shared/models/videos'
10import { readFile } from 'fs-extra' 10import { readFile } from 'fs-extra'
11import { getActivityStreamDuration } from '../models/video/video-format-utils'
11 12
12export class ClientHtml { 13export class ClientHtml {
13 14
@@ -150,7 +151,7 @@ export class ClientHtml {
150 description: videoDescriptionEscaped, 151 description: videoDescriptionEscaped,
151 thumbnailUrl: previewUrl, 152 thumbnailUrl: previewUrl,
152 uploadDate: video.createdAt.toISOString(), 153 uploadDate: video.createdAt.toISOString(),
153 duration: video.getActivityStreamDuration(), 154 duration: getActivityStreamDuration(video.duration),
154 contentUrl: videoUrl, 155 contentUrl: videoUrl,
155 embedUrl: embedUrl, 156 embedUrl: embedUrl,
156 interactionCount: video.views 157 interactionCount: video.views
diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts
index c6308f7a6..2c9ca8e12 100644
--- a/server/lib/job-queue/handlers/video-file.ts
+++ b/server/lib/job-queue/handlers/video-file.ts
@@ -8,6 +8,7 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { sequelizeTypescript } from '../../../initializers' 8import { sequelizeTypescript } from '../../../initializers'
9import * as Bluebird from 'bluebird' 9import * as Bluebird from 'bluebird'
10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' 10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
11import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding'
11 12
12export type VideoFilePayload = { 13export type VideoFilePayload = {
13 videoUUID: string 14 videoUUID: string
@@ -32,7 +33,7 @@ async function processVideoFileImport (job: Bull.Job) {
32 return undefined 33 return undefined
33 } 34 }
34 35
35 await video.importVideoFile(payload.filePath) 36 await importVideoFile(video, payload.filePath)
36 37
37 await onVideoFileTranscoderOrImportSuccess(video) 38 await onVideoFileTranscoderOrImportSuccess(video)
38 return video 39 return video
@@ -51,11 +52,11 @@ async function processVideoFile (job: Bull.Job) {
51 52
52 // Transcoding in other resolution 53 // Transcoding in other resolution
53 if (payload.resolution) { 54 if (payload.resolution) {
54 await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode || false) 55 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
55 56
56 await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video) 57 await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video)
57 } else { 58 } else {
58 await video.optimizeOriginalVideofile() 59 await optimizeOriginalVideofile(video)
59 60
60 await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo) 61 await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo)
61 } 62 }
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
new file mode 100644
index 000000000..bf3ff78c2
--- /dev/null
+++ b/server/lib/video-transcoding.ts
@@ -0,0 +1,130 @@
1import { CONFIG } from '../initializers'
2import { join, extname } from 'path'
3import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
4import { copy, remove, rename, stat } from 'fs-extra'
5import { logger } from '../helpers/logger'
6import { VideoResolution } from '../../shared/models/videos'
7import { VideoFileModel } from '../models/video/video-file'
8import { VideoModel } from '../models/video/video'
9
10async function optimizeOriginalVideofile (video: VideoModel) {
11 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
12 const newExtname = '.mp4'
13 const inputVideoFile = video.getOriginalFile()
14 const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
15 const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
16
17 const transcodeOptions = {
18 inputPath: videoInputPath,
19 outputPath: videoTranscodedPath
20 }
21
22 // Could be very long!
23 await transcode(transcodeOptions)
24
25 try {
26 await remove(videoInputPath)
27
28 // Important to do this before getVideoFilename() to take in account the new file extension
29 inputVideoFile.set('extname', newExtname)
30
31 const videoOutputPath = video.getVideoFilePath(inputVideoFile)
32 await rename(videoTranscodedPath, videoOutputPath)
33 const stats = await stat(videoOutputPath)
34 const fps = await getVideoFileFPS(videoOutputPath)
35
36 inputVideoFile.set('size', stats.size)
37 inputVideoFile.set('fps', fps)
38
39 await video.createTorrentAndSetInfoHash(inputVideoFile)
40 await inputVideoFile.save()
41 } catch (err) {
42 // Auto destruction...
43 video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
44
45 throw err
46 }
47}
48
49async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
50 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
51 const extname = '.mp4'
52
53 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
54 const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
55
56 const newVideoFile = new VideoFileModel({
57 resolution,
58 extname,
59 size: 0,
60 videoId: video.id
61 })
62 const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile))
63
64 const transcodeOptions = {
65 inputPath: videoInputPath,
66 outputPath: videoOutputPath,
67 resolution,
68 isPortraitMode
69 }
70
71 await transcode(transcodeOptions)
72
73 const stats = await stat(videoOutputPath)
74 const fps = await getVideoFileFPS(videoOutputPath)
75
76 newVideoFile.set('size', stats.size)
77 newVideoFile.set('fps', fps)
78
79 await video.createTorrentAndSetInfoHash(newVideoFile)
80
81 await newVideoFile.save()
82
83 video.VideoFiles.push(newVideoFile)
84}
85
86async function importVideoFile (video: VideoModel, inputFilePath: string) {
87 const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
88 const { size } = await stat(inputFilePath)
89 const fps = await getVideoFileFPS(inputFilePath)
90
91 let updatedVideoFile = new VideoFileModel({
92 resolution: videoFileResolution,
93 extname: extname(inputFilePath),
94 size,
95 fps,
96 videoId: video.id
97 })
98
99 const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
100
101 if (currentVideoFile) {
102 // Remove old file and old torrent
103 await video.removeFile(currentVideoFile)
104 await video.removeTorrent(currentVideoFile)
105 // Remove the old video file from the array
106 video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile)
107
108 // Update the database
109 currentVideoFile.set('extname', updatedVideoFile.extname)
110 currentVideoFile.set('size', updatedVideoFile.size)
111 currentVideoFile.set('fps', updatedVideoFile.fps)
112
113 updatedVideoFile = currentVideoFile
114 }
115
116 const outputPath = video.getVideoFilePath(updatedVideoFile)
117 await copy(inputFilePath, outputPath)
118
119 await video.createTorrentAndSetInfoHash(updatedVideoFile)
120
121 await updatedVideoFile.save()
122
123 video.VideoFiles.push(updatedVideoFile)
124}
125
126export {
127 optimizeOriginalVideofile,
128 transcodeOriginalVideofile,
129 importVideoFile
130}
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 6ae02efb9..fb07287a8 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -193,7 +193,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
193 // On VideoModel! 193 // On VideoModel!
194 const query = { 194 const query = {
195 attributes: [ 'id', 'publishedAt' ], 195 attributes: [ 'id', 'publishedAt' ],
196 // logging: !isTestInstance(), 196 logging: !isTestInstance(),
197 limit: randomizedFactor, 197 limit: randomizedFactor,
198 order: getVideoSort('-publishedAt'), 198 order: getVideoSort('-publishedAt'),
199 where: { 199 where: {
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
new file mode 100644
index 000000000..fae38507b
--- /dev/null
+++ b/server/models/video/video-format-utils.ts
@@ -0,0 +1,295 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
5import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption'
7import {
8 getVideoCommentsActivityPubUrl,
9 getVideoDislikesActivityPubUrl,
10 getVideoLikesActivityPubUrl,
11 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub'
13
14export type VideoFormattingJSONOptions = {
15 additionalAttributes: {
16 state?: boolean,
17 waitTranscoding?: boolean,
18 scheduledUpdate?: boolean,
19 blacklistInfo?: boolean
20 }
21}
22function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
23 const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
24 const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
25
26 const videoObject: Video = {
27 id: video.id,
28 uuid: video.uuid,
29 name: video.name,
30 category: {
31 id: video.category,
32 label: VideoModel.getCategoryLabel(video.category)
33 },
34 licence: {
35 id: video.licence,
36 label: VideoModel.getLicenceLabel(video.licence)
37 },
38 language: {
39 id: video.language,
40 label: VideoModel.getLanguageLabel(video.language)
41 },
42 privacy: {
43 id: video.privacy,
44 label: VideoModel.getPrivacyLabel(video.privacy)
45 },
46 nsfw: video.nsfw,
47 description: video.getTruncatedDescription(),
48 isLocal: video.isOwned(),
49 duration: video.duration,
50 views: video.views,
51 likes: video.likes,
52 dislikes: video.dislikes,
53 thumbnailPath: video.getThumbnailStaticPath(),
54 previewPath: video.getPreviewStaticPath(),
55 embedPath: video.getEmbedStaticPath(),
56 createdAt: video.createdAt,
57 updatedAt: video.updatedAt,
58 publishedAt: video.publishedAt,
59 account: {
60 id: formattedAccount.id,
61 uuid: formattedAccount.uuid,
62 name: formattedAccount.name,
63 displayName: formattedAccount.displayName,
64 url: formattedAccount.url,
65 host: formattedAccount.host,
66 avatar: formattedAccount.avatar
67 },
68 channel: {
69 id: formattedVideoChannel.id,
70 uuid: formattedVideoChannel.uuid,
71 name: formattedVideoChannel.name,
72 displayName: formattedVideoChannel.displayName,
73 url: formattedVideoChannel.url,
74 host: formattedVideoChannel.host,
75 avatar: formattedVideoChannel.avatar
76 }
77 }
78
79 if (options) {
80 if (options.additionalAttributes.state === true) {
81 videoObject.state = {
82 id: video.state,
83 label: VideoModel.getStateLabel(video.state)
84 }
85 }
86
87 if (options.additionalAttributes.waitTranscoding === true) {
88 videoObject.waitTranscoding = video.waitTranscoding
89 }
90
91 if (options.additionalAttributes.scheduledUpdate === true && video.ScheduleVideoUpdate) {
92 videoObject.scheduledUpdate = {
93 updateAt: video.ScheduleVideoUpdate.updateAt,
94 privacy: video.ScheduleVideoUpdate.privacy || undefined
95 }
96 }
97
98 if (options.additionalAttributes.blacklistInfo === true) {
99 videoObject.blacklisted = !!video.VideoBlacklist
100 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
101 }
102 }
103
104 return videoObject
105}
106
107function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
108 const formattedJson = video.toFormattedJSON({
109 additionalAttributes: {
110 scheduledUpdate: true,
111 blacklistInfo: true
112 }
113 })
114
115 const detailsJson = {
116 support: video.support,
117 descriptionPath: video.getDescriptionPath(),
118 channel: video.VideoChannel.toFormattedJSON(),
119 account: video.VideoChannel.Account.toFormattedJSON(),
120 tags: video.Tags.map(t => t.name),
121 commentsEnabled: video.commentsEnabled,
122 waitTranscoding: video.waitTranscoding,
123 state: {
124 id: video.state,
125 label: VideoModel.getStateLabel(video.state)
126 },
127 files: []
128 }
129
130 // Format and sort video files
131 detailsJson.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
132
133 return Object.assign(formattedJson, detailsJson)
134}
135
136function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
137 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
138
139 return videoFiles
140 .map(videoFile => {
141 let resolutionLabel = videoFile.resolution + 'p'
142
143 return {
144 resolution: {
145 id: videoFile.resolution,
146 label: resolutionLabel
147 },
148 magnetUri: video.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
149 size: videoFile.size,
150 fps: videoFile.fps,
151 torrentUrl: video.getTorrentUrl(videoFile, baseUrlHttp),
152 torrentDownloadUrl: video.getTorrentDownloadUrl(videoFile, baseUrlHttp),
153 fileUrl: video.getVideoFileUrl(videoFile, baseUrlHttp),
154 fileDownloadUrl: video.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
155 } as VideoFile
156 })
157 .sort((a, b) => {
158 if (a.resolution.id < b.resolution.id) return 1
159 if (a.resolution.id === b.resolution.id) return 0
160 return -1
161 })
162}
163
164function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
165 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
166 if (!video.Tags) video.Tags = []
167
168 const tag = video.Tags.map(t => ({
169 type: 'Hashtag' as 'Hashtag',
170 name: t.name
171 }))
172
173 let language
174 if (video.language) {
175 language = {
176 identifier: video.language,
177 name: VideoModel.getLanguageLabel(video.language)
178 }
179 }
180
181 let category
182 if (video.category) {
183 category = {
184 identifier: video.category + '',
185 name: VideoModel.getCategoryLabel(video.category)
186 }
187 }
188
189 let licence
190 if (video.licence) {
191 licence = {
192 identifier: video.licence + '',
193 name: VideoModel.getLicenceLabel(video.licence)
194 }
195 }
196
197 const url: ActivityUrlObject[] = []
198 for (const file of video.VideoFiles) {
199 url.push({
200 type: 'Link',
201 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
202 href: video.getVideoFileUrl(file, baseUrlHttp),
203 height: file.resolution,
204 size: file.size,
205 fps: file.fps
206 })
207
208 url.push({
209 type: 'Link',
210 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
211 href: video.getTorrentUrl(file, baseUrlHttp),
212 height: file.resolution
213 })
214
215 url.push({
216 type: 'Link',
217 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
218 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
219 height: file.resolution
220 })
221 }
222
223 // Add video url too
224 url.push({
225 type: 'Link',
226 mimeType: 'text/html',
227 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
228 })
229
230 const subtitleLanguage = []
231 for (const caption of video.VideoCaptions) {
232 subtitleLanguage.push({
233 identifier: caption.language,
234 name: VideoCaptionModel.getLanguageLabel(caption.language)
235 })
236 }
237
238 return {
239 type: 'Video' as 'Video',
240 id: video.url,
241 name: video.name,
242 duration: getActivityStreamDuration(video.duration),
243 uuid: video.uuid,
244 tag,
245 category,
246 licence,
247 language,
248 views: video.views,
249 sensitive: video.nsfw,
250 waitTranscoding: video.waitTranscoding,
251 state: video.state,
252 commentsEnabled: video.commentsEnabled,
253 published: video.publishedAt.toISOString(),
254 updated: video.updatedAt.toISOString(),
255 mediaType: 'text/markdown',
256 content: video.getTruncatedDescription(),
257 support: video.support,
258 subtitleLanguage,
259 icon: {
260 type: 'Image',
261 url: video.getThumbnailUrl(baseUrlHttp),
262 mediaType: 'image/jpeg',
263 width: THUMBNAILS_SIZE.width,
264 height: THUMBNAILS_SIZE.height
265 },
266 url,
267 likes: getVideoLikesActivityPubUrl(video),
268 dislikes: getVideoDislikesActivityPubUrl(video),
269 shares: getVideoSharesActivityPubUrl(video),
270 comments: getVideoCommentsActivityPubUrl(video),
271 attributedTo: [
272 {
273 type: 'Person',
274 id: video.VideoChannel.Account.Actor.url
275 },
276 {
277 type: 'Group',
278 id: video.VideoChannel.Actor.url
279 }
280 ]
281 }
282}
283
284function getActivityStreamDuration (duration: number) {
285 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
286 return 'PT' + duration + 'S'
287}
288
289export {
290 videoModelToFormattedJSON,
291 videoModelToFormattedDetailsJSON,
292 videoFilesModelToFormattedJSON,
293 videoModelToActivityPubObject,
294 getActivityStreamDuration
295}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index b7d3f184f..ce856aed2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,8 +1,8 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { map, maxBy } from 'lodash' 2import { maxBy } from 'lodash'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { extname, join } from 'path' 5import { join } from 'path'
6import * as Sequelize from 'sequelize' 6import * as Sequelize from 'sequelize'
7import { 7import {
8 AllowNull, 8 AllowNull,
@@ -27,7 +27,7 @@ import {
27 Table, 27 Table,
28 UpdatedAt 28 UpdatedAt
29} from 'sequelize-typescript' 29} from 'sequelize-typescript'
30import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared' 30import { VideoPrivacy, VideoState } from '../../../shared'
31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
33import { VideoFilter } from '../../../shared/models/videos/video-query.type' 33import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -45,7 +45,7 @@ import {
45 isVideoStateValid, 45 isVideoStateValid,
46 isVideoSupportValid 46 isVideoSupportValid
47} from '../../helpers/custom-validators/videos' 47} from '../../helpers/custom-validators/videos'
48import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' 48import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
49import { logger } from '../../helpers/logger' 49import { logger } from '../../helpers/logger'
50import { getServerActor } from '../../helpers/utils' 50import { getServerActor } from '../../helpers/utils'
51import { 51import {
@@ -59,18 +59,11 @@ import {
59 STATIC_PATHS, 59 STATIC_PATHS,
60 THUMBNAILS_SIZE, 60 THUMBNAILS_SIZE,
61 VIDEO_CATEGORIES, 61 VIDEO_CATEGORIES,
62 VIDEO_EXT_MIMETYPE,
63 VIDEO_LANGUAGES, 62 VIDEO_LANGUAGES,
64 VIDEO_LICENCES, 63 VIDEO_LICENCES,
65 VIDEO_PRIVACIES, 64 VIDEO_PRIVACIES,
66 VIDEO_STATES 65 VIDEO_STATES
67} from '../../initializers' 66} from '../../initializers'
68import {
69 getVideoCommentsActivityPubUrl,
70 getVideoDislikesActivityPubUrl,
71 getVideoLikesActivityPubUrl,
72 getVideoSharesActivityPubUrl
73} from '../../lib/activitypub'
74import { sendDeleteVideo } from '../../lib/activitypub/send' 67import { sendDeleteVideo } from '../../lib/activitypub/send'
75import { AccountModel } from '../account/account' 68import { AccountModel } from '../account/account'
76import { AccountVideoRateModel } from '../account/account-video-rate' 69import { AccountVideoRateModel } from '../account/account-video-rate'
@@ -88,9 +81,16 @@ import { VideoTagModel } from './video-tag'
88import { ScheduleVideoUpdateModel } from './schedule-video-update' 81import { ScheduleVideoUpdateModel } from './schedule-video-update'
89import { VideoCaptionModel } from './video-caption' 82import { VideoCaptionModel } from './video-caption'
90import { VideoBlacklistModel } from './video-blacklist' 83import { VideoBlacklistModel } from './video-blacklist'
91import { copy, remove, rename, stat, writeFile } from 'fs-extra' 84import { remove, writeFile } from 'fs-extra'
92import { VideoViewModel } from './video-views' 85import { VideoViewModel } from './video-views'
93import { VideoRedundancyModel } from '../redundancy/video-redundancy' 86import { VideoRedundancyModel } from '../redundancy/video-redundancy'
87import {
88 videoFilesModelToFormattedJSON,
89 VideoFormattingJSONOptions,
90 videoModelToActivityPubObject,
91 videoModelToFormattedDetailsJSON,
92 videoModelToFormattedJSON
93} from './video-format-utils'
94 94
95// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 95// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
96const indexes: Sequelize.DefineIndexesOptions[] = [ 96const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -1257,23 +1257,23 @@ export class VideoModel extends Model<VideoModel> {
1257 } 1257 }
1258 } 1258 }
1259 1259
1260 private static getCategoryLabel (id: number) { 1260 static getCategoryLabel (id: number) {
1261 return VIDEO_CATEGORIES[ id ] || 'Misc' 1261 return VIDEO_CATEGORIES[ id ] || 'Misc'
1262 } 1262 }
1263 1263
1264 private static getLicenceLabel (id: number) { 1264 static getLicenceLabel (id: number) {
1265 return VIDEO_LICENCES[ id ] || 'Unknown' 1265 return VIDEO_LICENCES[ id ] || 'Unknown'
1266 } 1266 }
1267 1267
1268 private static getLanguageLabel (id: string) { 1268 static getLanguageLabel (id: string) {
1269 return VIDEO_LANGUAGES[ id ] || 'Unknown' 1269 return VIDEO_LANGUAGES[ id ] || 'Unknown'
1270 } 1270 }
1271 1271
1272 private static getPrivacyLabel (id: number) { 1272 static getPrivacyLabel (id: number) {
1273 return VIDEO_PRIVACIES[ id ] || 'Unknown' 1273 return VIDEO_PRIVACIES[ id ] || 'Unknown'
1274 } 1274 }
1275 1275
1276 private static getStateLabel (id: number) { 1276 static getStateLabel (id: number) {
1277 return VIDEO_STATES[ id ] || 'Unknown' 1277 return VIDEO_STATES[ id ] || 'Unknown'
1278 } 1278 }
1279 1279
@@ -1369,273 +1369,20 @@ export class VideoModel extends Model<VideoModel> {
1369 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1369 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1370 } 1370 }
1371 1371
1372 toFormattedJSON (options?: { 1372 toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
1373 additionalAttributes: { 1373 return videoModelToFormattedJSON(this, options)
1374 state?: boolean,
1375 waitTranscoding?: boolean,
1376 scheduledUpdate?: boolean,
1377 blacklistInfo?: boolean
1378 }
1379 }): Video {
1380 const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
1381 const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
1382
1383 const videoObject: Video = {
1384 id: this.id,
1385 uuid: this.uuid,
1386 name: this.name,
1387 category: {
1388 id: this.category,
1389 label: VideoModel.getCategoryLabel(this.category)
1390 },
1391 licence: {
1392 id: this.licence,
1393 label: VideoModel.getLicenceLabel(this.licence)
1394 },
1395 language: {
1396 id: this.language,
1397 label: VideoModel.getLanguageLabel(this.language)
1398 },
1399 privacy: {
1400 id: this.privacy,
1401 label: VideoModel.getPrivacyLabel(this.privacy)
1402 },
1403 nsfw: this.nsfw,
1404 description: this.getTruncatedDescription(),
1405 isLocal: this.isOwned(),
1406 duration: this.duration,
1407 views: this.views,
1408 likes: this.likes,
1409 dislikes: this.dislikes,
1410 thumbnailPath: this.getThumbnailStaticPath(),
1411 previewPath: this.getPreviewStaticPath(),
1412 embedPath: this.getEmbedStaticPath(),
1413 createdAt: this.createdAt,
1414 updatedAt: this.updatedAt,
1415 publishedAt: this.publishedAt,
1416 account: {
1417 id: formattedAccount.id,
1418 uuid: formattedAccount.uuid,
1419 name: formattedAccount.name,
1420 displayName: formattedAccount.displayName,
1421 url: formattedAccount.url,
1422 host: formattedAccount.host,
1423 avatar: formattedAccount.avatar
1424 },
1425 channel: {
1426 id: formattedVideoChannel.id,
1427 uuid: formattedVideoChannel.uuid,
1428 name: formattedVideoChannel.name,
1429 displayName: formattedVideoChannel.displayName,
1430 url: formattedVideoChannel.url,
1431 host: formattedVideoChannel.host,
1432 avatar: formattedVideoChannel.avatar
1433 }
1434 }
1435
1436 if (options) {
1437 if (options.additionalAttributes.state === true) {
1438 videoObject.state = {
1439 id: this.state,
1440 label: VideoModel.getStateLabel(this.state)
1441 }
1442 }
1443
1444 if (options.additionalAttributes.waitTranscoding === true) {
1445 videoObject.waitTranscoding = this.waitTranscoding
1446 }
1447
1448 if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
1449 videoObject.scheduledUpdate = {
1450 updateAt: this.ScheduleVideoUpdate.updateAt,
1451 privacy: this.ScheduleVideoUpdate.privacy || undefined
1452 }
1453 }
1454
1455 if (options.additionalAttributes.blacklistInfo === true) {
1456 videoObject.blacklisted = !!this.VideoBlacklist
1457 videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
1458 }
1459 }
1460
1461 return videoObject
1462 } 1374 }
1463 1375
1464 toFormattedDetailsJSON (): VideoDetails { 1376 toFormattedDetailsJSON (): VideoDetails {
1465 const formattedJson = this.toFormattedJSON({ 1377 return videoModelToFormattedDetailsJSON(this)
1466 additionalAttributes: {
1467 scheduledUpdate: true,
1468 blacklistInfo: true
1469 }
1470 })
1471
1472 const detailsJson = {
1473 support: this.support,
1474 descriptionPath: this.getDescriptionPath(),
1475 channel: this.VideoChannel.toFormattedJSON(),
1476 account: this.VideoChannel.Account.toFormattedJSON(),
1477 tags: map(this.Tags, 'name'),
1478 commentsEnabled: this.commentsEnabled,
1479 waitTranscoding: this.waitTranscoding,
1480 state: {
1481 id: this.state,
1482 label: VideoModel.getStateLabel(this.state)
1483 },
1484 files: []
1485 }
1486
1487 // Format and sort video files
1488 detailsJson.files = this.getFormattedVideoFilesJSON()
1489
1490 return Object.assign(formattedJson, detailsJson)
1491 } 1378 }
1492 1379
1493 getFormattedVideoFilesJSON (): VideoFile[] { 1380 getFormattedVideoFilesJSON (): VideoFile[] {
1494 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1381 return videoFilesModelToFormattedJSON(this, this.VideoFiles)
1495
1496 return this.VideoFiles
1497 .map(videoFile => {
1498 let resolutionLabel = videoFile.resolution + 'p'
1499
1500 return {
1501 resolution: {
1502 id: videoFile.resolution,
1503 label: resolutionLabel
1504 },
1505 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1506 size: videoFile.size,
1507 fps: videoFile.fps,
1508 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1509 torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
1510 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
1511 fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
1512 } as VideoFile
1513 })
1514 .sort((a, b) => {
1515 if (a.resolution.id < b.resolution.id) return 1
1516 if (a.resolution.id === b.resolution.id) return 0
1517 return -1
1518 })
1519 } 1382 }
1520 1383
1521 toActivityPubObject (): VideoTorrentObject { 1384 toActivityPubObject (): VideoTorrentObject {
1522 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1385 return videoModelToActivityPubObject(this)
1523 if (!this.Tags) this.Tags = []
1524
1525 const tag = this.Tags.map(t => ({
1526 type: 'Hashtag' as 'Hashtag',
1527 name: t.name
1528 }))
1529
1530 let language
1531 if (this.language) {
1532 language = {
1533 identifier: this.language,
1534 name: VideoModel.getLanguageLabel(this.language)
1535 }
1536 }
1537
1538 let category
1539 if (this.category) {
1540 category = {
1541 identifier: this.category + '',
1542 name: VideoModel.getCategoryLabel(this.category)
1543 }
1544 }
1545
1546 let licence
1547 if (this.licence) {
1548 licence = {
1549 identifier: this.licence + '',
1550 name: VideoModel.getLicenceLabel(this.licence)
1551 }
1552 }
1553
1554 const url: ActivityUrlObject[] = []
1555 for (const file of this.VideoFiles) {
1556 url.push({
1557 type: 'Link',
1558 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
1559 href: this.getVideoFileUrl(file, baseUrlHttp),
1560 height: file.resolution,
1561 size: file.size,
1562 fps: file.fps
1563 })
1564
1565 url.push({
1566 type: 'Link',
1567 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
1568 href: this.getTorrentUrl(file, baseUrlHttp),
1569 height: file.resolution
1570 })
1571
1572 url.push({
1573 type: 'Link',
1574 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
1575 href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1576 height: file.resolution
1577 })
1578 }
1579
1580 // Add video url too
1581 url.push({
1582 type: 'Link',
1583 mimeType: 'text/html',
1584 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1585 })
1586
1587 const subtitleLanguage = []
1588 for (const caption of this.VideoCaptions) {
1589 subtitleLanguage.push({
1590 identifier: caption.language,
1591 name: VideoCaptionModel.getLanguageLabel(caption.language)
1592 })
1593 }
1594
1595 return {
1596 type: 'Video' as 'Video',
1597 id: this.url,
1598 name: this.name,
1599 duration: this.getActivityStreamDuration(),
1600 uuid: this.uuid,
1601 tag,
1602 category,
1603 licence,
1604 language,
1605 views: this.views,
1606 sensitive: this.nsfw,
1607 waitTranscoding: this.waitTranscoding,
1608 state: this.state,
1609 commentsEnabled: this.commentsEnabled,
1610 published: this.publishedAt.toISOString(),
1611 updated: this.updatedAt.toISOString(),
1612 mediaType: 'text/markdown',
1613 content: this.getTruncatedDescription(),
1614 support: this.support,
1615 subtitleLanguage,
1616 icon: {
1617 type: 'Image',
1618 url: this.getThumbnailUrl(baseUrlHttp),
1619 mediaType: 'image/jpeg',
1620 width: THUMBNAILS_SIZE.width,
1621 height: THUMBNAILS_SIZE.height
1622 },
1623 url,
1624 likes: getVideoLikesActivityPubUrl(this),
1625 dislikes: getVideoDislikesActivityPubUrl(this),
1626 shares: getVideoSharesActivityPubUrl(this),
1627 comments: getVideoCommentsActivityPubUrl(this),
1628 attributedTo: [
1629 {
1630 type: 'Person',
1631 id: this.VideoChannel.Account.Actor.url
1632 },
1633 {
1634 type: 'Group',
1635 id: this.VideoChannel.Actor.url
1636 }
1637 ]
1638 }
1639 } 1386 }
1640 1387
1641 getTruncatedDescription () { 1388 getTruncatedDescription () {
@@ -1645,123 +1392,6 @@ export class VideoModel extends Model<VideoModel> {
1645 return peertubeTruncate(this.description, maxLength) 1392 return peertubeTruncate(this.description, maxLength)
1646 } 1393 }
1647 1394
1648 async optimizeOriginalVideofile () {
1649 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1650 const newExtname = '.mp4'
1651 const inputVideoFile = this.getOriginalFile()
1652 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1653 const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1654
1655 const transcodeOptions = {
1656 inputPath: videoInputPath,
1657 outputPath: videoTranscodedPath
1658 }
1659
1660 // Could be very long!
1661 await transcode(transcodeOptions)
1662
1663 try {
1664 await remove(videoInputPath)
1665
1666 // Important to do this before getVideoFilename() to take in account the new file extension
1667 inputVideoFile.set('extname', newExtname)
1668
1669 const videoOutputPath = this.getVideoFilePath(inputVideoFile)
1670 await rename(videoTranscodedPath, videoOutputPath)
1671 const stats = await stat(videoOutputPath)
1672 const fps = await getVideoFileFPS(videoOutputPath)
1673
1674 inputVideoFile.set('size', stats.size)
1675 inputVideoFile.set('fps', fps)
1676
1677 await this.createTorrentAndSetInfoHash(inputVideoFile)
1678 await inputVideoFile.save()
1679
1680 } catch (err) {
1681 // Auto destruction...
1682 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1683
1684 throw err
1685 }
1686 }
1687
1688 async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1689 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1690 const extname = '.mp4'
1691
1692 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1693 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1694
1695 const newVideoFile = new VideoFileModel({
1696 resolution,
1697 extname,
1698 size: 0,
1699 videoId: this.id
1700 })
1701 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1702
1703 const transcodeOptions = {
1704 inputPath: videoInputPath,
1705 outputPath: videoOutputPath,
1706 resolution,
1707 isPortraitMode
1708 }
1709
1710 await transcode(transcodeOptions)
1711
1712 const stats = await stat(videoOutputPath)
1713 const fps = await getVideoFileFPS(videoOutputPath)
1714
1715 newVideoFile.set('size', stats.size)
1716 newVideoFile.set('fps', fps)
1717
1718 await this.createTorrentAndSetInfoHash(newVideoFile)
1719
1720 await newVideoFile.save()
1721
1722 this.VideoFiles.push(newVideoFile)
1723 }
1724
1725 async importVideoFile (inputFilePath: string) {
1726 const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
1727 const { size } = await stat(inputFilePath)
1728 const fps = await getVideoFileFPS(inputFilePath)
1729
1730 let updatedVideoFile = new VideoFileModel({
1731 resolution: videoFileResolution,
1732 extname: extname(inputFilePath),
1733 size,
1734 fps,
1735 videoId: this.id
1736 })
1737
1738 const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
1739
1740 if (currentVideoFile) {
1741 // Remove old file and old torrent
1742 await this.removeFile(currentVideoFile)
1743 await this.removeTorrent(currentVideoFile)
1744 // Remove the old video file from the array
1745 this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
1746
1747 // Update the database
1748 currentVideoFile.set('extname', updatedVideoFile.extname)
1749 currentVideoFile.set('size', updatedVideoFile.size)
1750 currentVideoFile.set('fps', updatedVideoFile.fps)
1751
1752 updatedVideoFile = currentVideoFile
1753 }
1754
1755 const outputPath = this.getVideoFilePath(updatedVideoFile)
1756 await copy(inputFilePath, outputPath)
1757
1758 await this.createTorrentAndSetInfoHash(updatedVideoFile)
1759
1760 await updatedVideoFile.save()
1761
1762 this.VideoFiles.push(updatedVideoFile)
1763 }
1764
1765 getOriginalFileResolution () { 1395 getOriginalFileResolution () {
1766 const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) 1396 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1767 1397
@@ -1796,11 +1426,6 @@ export class VideoModel extends Model<VideoModel> {
1796 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1426 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1797 } 1427 }
1798 1428
1799 getActivityStreamDuration () {
1800 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1801 return 'PT' + this.duration + 'S'
1802 }
1803
1804 isOutdated () { 1429 isOutdated () {
1805 if (this.isOwned()) return false 1430 if (this.isOwned()) return false
1806 1431