]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video.ts
Fix error display on signup page
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
CommitLineData
39445ead 1import * as Bluebird from 'bluebird'
53abc4c2 2import { map, maxBy, truncate } from 'lodash'
571389d4 3import * as magnetUtil from 'magnet-uri'
4d4e5cd4 4import * as parseTorrent from 'parse-torrent'
65fcc311 5import { join } from 'path'
e02643f3 6import * as Sequelize from 'sequelize'
571389d4
C
7import { VideoPrivacy, VideoResolution } from '../../../shared'
8import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
39445ead 9import { activityPubCollection } from '../../helpers/activitypub'
a2431b7d
C
10import { createTorrentPromise, renamePromise, statPromise, unlinkPromise, writeFilePromise } from '../../helpers/core-utils'
11import { isVideoCategoryValid, isVideoLanguageValid, isVideoPrivacyValid } from '../../helpers/custom-validators/videos'
12import { generateImageFromVideoFile, getVideoFileHeight, transcode } from '../../helpers/ffmpeg-utils'
13import {
14 isActivityPubUrlValid,
15 isVideoDescriptionValid,
16 isVideoDurationValid,
17 isVideoLicenceValid,
18 isVideoNameValid,
19 isVideoNSFWValid
20} from '../../helpers/index'
21import { logger } from '../../helpers/logger'
65fcc311 22import {
571389d4 23 API_VERSION,
65fcc311 24 CONFIG,
571389d4
C
25 CONSTRAINTS_FIELDS,
26 PREVIEWS_SIZE,
65fcc311
C
27 REMOTE_SCHEME,
28 STATIC_PATHS,
571389d4 29 THUMBNAILS_SIZE,
65fcc311 30 VIDEO_CATEGORIES,
65fcc311 31 VIDEO_LANGUAGES,
571389d4 32 VIDEO_LICENCES,
fd45e8f4 33 VIDEO_PRIVACIES
63c93323 34} from '../../initializers/constants'
a2431b7d 35import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url'
39445ead 36import { sendDeleteVideo } from '../../lib/index'
74889a71 37import { addMethodsToModel, getSort } from '../utils'
571389d4
C
38import { TagInstance } from './tag-interface'
39import { VideoFileInstance, VideoFileModel } from './video-file-interface'
40import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
e02643f3
C
41
42let Video: Sequelize.Model<VideoInstance, VideoAttributes>
40298b02 43let getOriginalFile: VideoMethods.GetOriginalFile
e02643f3
C
44let getVideoFilename: VideoMethods.GetVideoFilename
45let getThumbnailName: VideoMethods.GetThumbnailName
d8755eed 46let getThumbnailPath: VideoMethods.GetThumbnailPath
e02643f3 47let getPreviewName: VideoMethods.GetPreviewName
d8755eed 48let getPreviewPath: VideoMethods.GetPreviewPath
93e1258c 49let getTorrentFileName: VideoMethods.GetTorrentFileName
e02643f3 50let isOwned: VideoMethods.IsOwned
0aef76c4 51let toFormattedJSON: VideoMethods.ToFormattedJSON
72c7248b 52let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
e4f97bab 53let toActivityPubObject: VideoMethods.ToActivityPubObject
40298b02
C
54let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
55let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
93e1258c
C
56let createPreview: VideoMethods.CreatePreview
57let createThumbnail: VideoMethods.CreateThumbnail
58let getVideoFilePath: VideoMethods.GetVideoFilePath
59let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
40298b02 60let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
d8755eed 61let getEmbedPath: VideoMethods.GetEmbedPath
9567011b
C
62let getDescriptionPath: VideoMethods.GetDescriptionPath
63let getTruncatedDescription: VideoMethods.GetTruncatedDescription
e4f97bab
C
64let getCategoryLabel: VideoMethods.GetCategoryLabel
65let getLicenceLabel: VideoMethods.GetLicenceLabel
66let getLanguageLabel: VideoMethods.GetLanguageLabel
e02643f3 67
e02643f3
C
68let list: VideoMethods.List
69let listForApi: VideoMethods.ListForApi
e71bcc0f 70let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
fd45e8f4 71let listUserVideosForApi: VideoMethods.ListUserVideosForApi
e02643f3 72let load: VideoMethods.Load
d7d5611c 73let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
0a6658fd 74let loadByUUID: VideoMethods.LoadByUUID
0d0e8dd0 75let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
60862425
C
76let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
77let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
78let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
93e1258c
C
79let removeThumbnail: VideoMethods.RemoveThumbnail
80let removePreview: VideoMethods.RemovePreview
81let removeFile: VideoMethods.RemoveFile
82let removeTorrent: VideoMethods.RemoveTorrent
e02643f3 83
127944aa
C
84export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
85 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
feb4bdfd 86 {
0a6658fd 87 uuid: {
feb4bdfd
C
88 type: DataTypes.UUID,
89 defaultValue: DataTypes.UUIDV4,
0a6658fd 90 allowNull: false,
67bf9b96
C
91 validate: {
92 isUUID: 4
93 }
aaf61f38 94 },
feb4bdfd 95 name: {
67bf9b96
C
96 type: DataTypes.STRING,
97 allowNull: false,
98 validate: {
075f16ca 99 nameValid: value => {
65fcc311 100 const res = isVideoNameValid(value)
67bf9b96
C
101 if (res === false) throw new Error('Video name is not valid.')
102 }
103 }
6a94a109 104 },
6e07c3de
C
105 category: {
106 type: DataTypes.INTEGER,
107 allowNull: false,
108 validate: {
075f16ca 109 categoryValid: value => {
65fcc311 110 const res = isVideoCategoryValid(value)
6e07c3de
C
111 if (res === false) throw new Error('Video category is not valid.')
112 }
113 }
114 },
6f0c39e2
C
115 licence: {
116 type: DataTypes.INTEGER,
117 allowNull: false,
3092476e 118 defaultValue: null,
6f0c39e2 119 validate: {
075f16ca 120 licenceValid: value => {
65fcc311 121 const res = isVideoLicenceValid(value)
6f0c39e2
C
122 if (res === false) throw new Error('Video licence is not valid.')
123 }
124 }
125 },
3092476e
C
126 language: {
127 type: DataTypes.INTEGER,
128 allowNull: true,
129 validate: {
075f16ca 130 languageValid: value => {
65fcc311 131 const res = isVideoLanguageValid(value)
3092476e
C
132 if (res === false) throw new Error('Video language is not valid.')
133 }
134 }
135 },
fd45e8f4
C
136 privacy: {
137 type: DataTypes.INTEGER,
138 allowNull: false,
139 validate: {
140 privacyValid: value => {
141 const res = isVideoPrivacyValid(value)
142 if (res === false) throw new Error('Video privacy is not valid.')
143 }
144 }
145 },
31b59b47
C
146 nsfw: {
147 type: DataTypes.BOOLEAN,
148 allowNull: false,
149 validate: {
075f16ca 150 nsfwValid: value => {
65fcc311 151 const res = isVideoNSFWValid(value)
31b59b47
C
152 if (res === false) throw new Error('Video nsfw attribute is not valid.')
153 }
154 }
155 },
feb4bdfd 156 description: {
9567011b 157 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
67bf9b96
C
158 allowNull: false,
159 validate: {
075f16ca 160 descriptionValid: value => {
65fcc311 161 const res = isVideoDescriptionValid(value)
67bf9b96
C
162 if (res === false) throw new Error('Video description is not valid.')
163 }
164 }
feb4bdfd 165 },
feb4bdfd 166 duration: {
67bf9b96
C
167 type: DataTypes.INTEGER,
168 allowNull: false,
169 validate: {
075f16ca 170 durationValid: value => {
65fcc311 171 const res = isVideoDurationValid(value)
67bf9b96
C
172 if (res === false) throw new Error('Video duration is not valid.')
173 }
174 }
9e167724
C
175 },
176 views: {
177 type: DataTypes.INTEGER,
178 allowNull: false,
179 defaultValue: 0,
180 validate: {
181 min: 0,
182 isInt: true
183 }
d38b8281
C
184 },
185 likes: {
186 type: DataTypes.INTEGER,
187 allowNull: false,
188 defaultValue: 0,
189 validate: {
190 min: 0,
191 isInt: true
192 }
193 },
194 dislikes: {
195 type: DataTypes.INTEGER,
196 allowNull: false,
197 defaultValue: 0,
198 validate: {
199 min: 0,
200 isInt: true
201 }
0a6658fd
C
202 },
203 remote: {
204 type: DataTypes.BOOLEAN,
205 allowNull: false,
206 defaultValue: false
e4f97bab
C
207 },
208 url: {
e34c85e5 209 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
e4f97bab
C
210 allowNull: false,
211 validate: {
e34c85e5 212 urlValid: value => {
a2431b7d 213 const res = isActivityPubUrlValid(value)
e34c85e5
C
214 if (res === false) throw new Error('Video URL is not valid.')
215 }
e4f97bab 216 }
aaf61f38 217 }
feb4bdfd
C
218 },
219 {
319d072e 220 indexes: [
319d072e
C
221 {
222 fields: [ 'name' ]
223 },
224 {
225 fields: [ 'createdAt' ]
226 },
227 {
228 fields: [ 'duration' ]
229 },
9e167724
C
230 {
231 fields: [ 'views' ]
d38b8281
C
232 },
233 {
234 fields: [ 'likes' ]
0a6658fd
C
235 },
236 {
237 fields: [ 'uuid' ]
72c7248b
C
238 },
239 {
240 fields: [ 'channelId' ]
319d072e
C
241 }
242 ],
feb4bdfd 243 hooks: {
feb4bdfd
C
244 afterDestroy
245 }
246 }
247 )
aaf61f38 248
e02643f3
C
249 const classMethods = [
250 associate,
251
e02643f3 252 list,
e71bcc0f 253 listAllAndSharedByAccountForOutbox,
e02643f3 254 listForApi,
fd45e8f4 255 listUserVideosForApi,
e02643f3 256 load,
d7d5611c 257 loadByUrlAndPopulateAccount,
60862425 258 loadAndPopulateAccountAndServerAndTags,
0d0e8dd0 259 loadByUUIDOrURL,
93e1258c 260 loadByUUID,
60862425
C
261 loadByUUIDAndPopulateAccountAndServerAndTags,
262 searchAndPopulateAccountAndServerAndTags
e02643f3
C
263 ]
264 const instanceMethods = [
93e1258c
C
265 createPreview,
266 createThumbnail,
267 createTorrentAndSetInfoHash,
e02643f3 268 getPreviewName,
d8755eed 269 getPreviewPath,
93e1258c 270 getThumbnailName,
d8755eed 271 getThumbnailPath,
93e1258c
C
272 getTorrentFileName,
273 getVideoFilename,
274 getVideoFilePath,
40298b02 275 getOriginalFile,
e02643f3 276 isOwned,
93e1258c
C
277 removeFile,
278 removePreview,
279 removeThumbnail,
280 removeTorrent,
e4f97bab 281 toActivityPubObject,
0aef76c4 282 toFormattedJSON,
72c7248b 283 toFormattedDetailsJSON,
40298b02
C
284 optimizeOriginalVideofile,
285 transcodeOriginalVideofile,
d8755eed 286 getOriginalFileHeight,
9567011b
C
287 getEmbedPath,
288 getTruncatedDescription,
e4f97bab
C
289 getDescriptionPath,
290 getCategoryLabel,
291 getLicenceLabel,
292 getLanguageLabel
e02643f3
C
293 ]
294 addMethodsToModel(Video, classMethods, instanceMethods)
295
feb4bdfd
C
296 return Video
297}
aaf61f38 298
aaf61f38
C
299// ------------------------------ METHODS ------------------------------
300
feb4bdfd 301function associate (models) {
72c7248b 302 Video.belongsTo(models.VideoChannel, {
feb4bdfd 303 foreignKey: {
72c7248b 304 name: 'channelId',
feb4bdfd
C
305 allowNull: false
306 },
307 onDelete: 'cascade'
308 })
7920c273 309
e02643f3 310 Video.belongsToMany(models.Tag, {
7920c273
C
311 foreignKey: 'videoId',
312 through: models.VideoTag,
313 onDelete: 'cascade'
314 })
55fa55a9 315
e02643f3 316 Video.hasMany(models.VideoAbuse, {
55fa55a9
C
317 foreignKey: {
318 name: 'videoId',
319 allowNull: false
320 },
321 onDelete: 'cascade'
322 })
93e1258c
C
323
324 Video.hasMany(models.VideoFile, {
325 foreignKey: {
326 name: 'videoId',
327 allowNull: false
328 },
329 onDelete: 'cascade'
330 })
e71bcc0f
C
331
332 Video.hasMany(models.VideoShare, {
333 foreignKey: {
334 name: 'videoId',
335 allowNull: false
336 },
337 onDelete: 'cascade'
338 })
16b90975
C
339
340 Video.hasMany(models.AccountVideoRate, {
341 foreignKey: {
342 name: 'videoId',
343 allowNull: false
344 },
345 onDelete: 'cascade'
346 })
feb4bdfd
C
347}
348
911238e3 349function afterDestroy (video: VideoInstance) {
93e1258c 350 const tasks = []
f285faa0 351
93e1258c
C
352 tasks.push(
353 video.removeThumbnail()
354 )
f285faa0 355
93e1258c 356 if (video.isOwned()) {
93e1258c 357 tasks.push(
7a7724e6
C
358 video.removePreview(),
359 sendDeleteVideo(video, undefined)
93e1258c
C
360 )
361
91f6f169 362 // Remove physical files and torrents
93e1258c 363 video.VideoFiles.forEach(file => {
9fd54056
C
364 tasks.push(video.removeFile(file))
365 tasks.push(video.removeTorrent(file))
93e1258c 366 })
f285faa0
C
367 }
368
93e1258c 369 return Promise.all(tasks)
9fd54056 370 .catch(err => {
6cd44728 371 logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
9fd54056 372 })
558d7c23
C
373}
374
40298b02
C
375getOriginalFile = function (this: VideoInstance) {
376 if (Array.isArray(this.VideoFiles) === false) return undefined
377
14d3270f
C
378 // The original file is the file that have the higher resolution
379 return maxBy(this.VideoFiles, file => file.resolution)
40298b02
C
380}
381
93e1258c 382getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
14d3270f 383 return this.uuid + '-' + videoFile.resolution + videoFile.extname
f285faa0
C
384}
385
70c065d6 386getThumbnailName = function (this: VideoInstance) {
f285faa0 387 // We always have a copy of the thumbnail
0a6658fd
C
388 const extension = '.jpg'
389 return this.uuid + extension
558d7c23
C
390}
391
70c065d6 392getPreviewName = function (this: VideoInstance) {
f285faa0 393 const extension = '.jpg'
0a6658fd 394 return this.uuid + extension
f285faa0
C
395}
396
93e1258c 397getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
f285faa0 398 const extension = '.torrent'
14d3270f 399 return this.uuid + '-' + videoFile.resolution + extension
558d7c23
C
400}
401
70c065d6 402isOwned = function (this: VideoInstance) {
0a6658fd 403 return this.remote === false
aaf61f38
C
404}
405
93e1258c 406createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
164174a6
C
407 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
408
14d3270f
C
409 return generateImageFromVideoFile(
410 this.getVideoFilePath(videoFile),
411 CONFIG.STORAGE.PREVIEWS_DIR,
164174a6
C
412 this.getPreviewName(),
413 imageSize
14d3270f 414 )
93e1258c
C
415}
416
417createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
d8755eed
C
418 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
419
14d3270f
C
420 return generateImageFromVideoFile(
421 this.getVideoFilePath(videoFile),
422 CONFIG.STORAGE.THUMBNAILS_DIR,
423 this.getThumbnailName(),
d8755eed 424 imageSize
14d3270f 425 )
93e1258c
C
426}
427
428getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
429 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
430}
431
e4f97bab 432createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) {
93e1258c
C
433 const options = {
434 announceList: [
435 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
436 ],
437 urlList: [
438 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
439 ]
440 }
441
e4f97bab 442 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
fdbda9e3 443
e4f97bab
C
444 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
445 logger.info('Creating torrent %s.', filePath)
93e1258c 446
e4f97bab
C
447 await writeFilePromise(filePath, torrent)
448
449 const parsedTorrent = parseTorrent(torrent)
450 videoFile.infoHash = parsedTorrent.infoHash
93e1258c
C
451}
452
d8755eed
C
453getEmbedPath = function (this: VideoInstance) {
454 return '/videos/embed/' + this.uuid
455}
456
457getThumbnailPath = function (this: VideoInstance) {
458 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
459}
460
461getPreviewPath = function (this: VideoInstance) {
462 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
463}
464
0aef76c4 465toFormattedJSON = function (this: VideoInstance) {
60862425 466 let serverHost
feb4bdfd 467
60862425
C
468 if (this.VideoChannel.Account.Server) {
469 serverHost = this.VideoChannel.Account.Server.host
feb4bdfd
C
470 } else {
471 // It means it's our video
60862425 472 serverHost = CONFIG.WEBSERVER.HOST
feb4bdfd
C
473 }
474
aaf61f38 475 const json = {
feb4bdfd 476 id: this.id,
0a6658fd 477 uuid: this.uuid,
aaf61f38 478 name: this.name,
6e07c3de 479 category: this.category,
e4f97bab 480 categoryLabel: this.getCategoryLabel(),
6f0c39e2 481 licence: this.licence,
e4f97bab 482 licenceLabel: this.getLicenceLabel(),
3092476e 483 language: this.language,
e4f97bab 484 languageLabel: this.getLanguageLabel(),
31b59b47 485 nsfw: this.nsfw,
9567011b 486 description: this.getTruncatedDescription(),
60862425 487 serverHost,
aaf61f38 488 isLocal: this.isOwned(),
e4f97bab 489 account: this.VideoChannel.Account.name,
72c7248b
C
490 duration: this.duration,
491 views: this.views,
492 likes: this.likes,
493 dislikes: this.dislikes,
494 tags: map<TagInstance, string>(this.Tags, 'name'),
495 thumbnailPath: this.getThumbnailPath(),
496 previewPath: this.getPreviewPath(),
497 embedPath: this.getEmbedPath(),
498 createdAt: this.createdAt,
499 updatedAt: this.updatedAt
500 }
501
502 return json
503}
504
505toFormattedDetailsJSON = function (this: VideoInstance) {
9567011b 506 const formattedJson = this.toFormattedJSON()
72c7248b 507
60862425 508 // Maybe our server is not up to date and there are new privacy settings since our version
fd45e8f4
C
509 let privacyLabel = VIDEO_PRIVACIES[this.privacy]
510 if (!privacyLabel) privacyLabel = 'Unknown'
511
9567011b 512 const detailsJson = {
fd45e8f4
C
513 privacyLabel,
514 privacy: this.privacy,
9567011b 515 descriptionPath: this.getDescriptionPath(),
72c7248b 516 channel: this.VideoChannel.toFormattedJSON(),
93e1258c 517 files: []
aaf61f38
C
518 }
519
aa8b6df4 520 // Format and sort video files
a96aed15 521 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
9567011b 522 detailsJson.files = this.VideoFiles
aa8b6df4 523 .map(videoFile => {
14d3270f 524 let resolutionLabel = videoFile.resolution + 'p'
aa8b6df4
C
525
526 const videoFileJson = {
527 resolution: videoFile.resolution,
528 resolutionLabel,
a96aed15
C
529 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
530 size: videoFile.size,
531 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
532 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
aa8b6df4
C
533 }
534
535 return videoFileJson
536 })
537 .sort((a, b) => {
538 if (a.resolution < b.resolution) return 1
539 if (a.resolution === b.resolution) return 0
540 return -1
541 })
93e1258c 542
9567011b 543 return Object.assign(formattedJson, detailsJson)
aaf61f38
C
544}
545
e4f97bab
C
546toActivityPubObject = function (this: VideoInstance) {
547 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
9a27cdc2 548 if (!this.Tags) this.Tags = []
aaf61f38 549
e4f97bab 550 const tag = this.Tags.map(t => ({
571389d4 551 type: 'Hashtag' as 'Hashtag',
e4f97bab
C
552 name: t.name
553 }))
554
40ff5707
C
555 let language
556 if (this.language) {
557 language = {
558 identifier: this.language + '',
559 name: this.getLanguageLabel()
560 }
561 }
562
16b90975
C
563 let likesObject
564 let dislikesObject
565
566 if (Array.isArray(this.AccountVideoRates)) {
567 const likes: string[] = []
568 const dislikes: string[] = []
569
570 for (const rate of this.AccountVideoRates) {
571 if (rate.type === 'like') {
572 likes.push(rate.Account.url)
573 } else if (rate.type === 'dislike') {
574 dislikes.push(rate.Account.url)
575 }
576 }
577
578 likesObject = activityPubCollection(likes)
579 dislikesObject = activityPubCollection(dislikes)
580 }
581
4e50b6a1
C
582 let sharesObject
583 if (Array.isArray(this.VideoShares)) {
584 const shares: string[] = []
585
586 for (const videoShare of this.VideoShares) {
587 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
588 shares.push(shareUrl)
589 }
590
591 sharesObject = activityPubCollection(shares)
592 }
593
e4f97bab
C
594 const url = []
595 for (const file of this.VideoFiles) {
596 url.push({
597 type: 'Link',
efc32059 598 mimeType: 'video/' + file.extname.replace('.', ''),
e4f97bab
C
599 url: getVideoFileUrl(this, file, baseUrlHttp),
600 width: file.resolution,
601 size: file.size
602 })
aaf61f38 603
e4f97bab
C
604 url.push({
605 type: 'Link',
606 mimeType: 'application/x-bittorrent',
607 url: getTorrentUrl(this, file, baseUrlHttp),
608 width: file.resolution
93e1258c
C
609 })
610
e4f97bab
C
611 url.push({
612 type: 'Link',
613 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
614 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
615 width: file.resolution
616 })
617 }
aaf61f38 618
e4f97bab 619 const videoObject: VideoTorrentObject = {
571389d4 620 type: 'Video' as 'Video',
54141398 621 id: this.url,
7b1f49de 622 name: this.name,
e4f97bab
C
623 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
624 duration: 'PT' + this.duration + 'S',
625 uuid: this.uuid,
626 tag,
627 category: {
571389d4
C
628 identifier: this.category + '',
629 name: this.getCategoryLabel()
e4f97bab
C
630 },
631 licence: {
571389d4 632 identifier: this.licence + '',
e4f97bab
C
633 name: this.getLicenceLabel()
634 },
40ff5707 635 language,
d38b8281 636 views: this.views,
e4f97bab 637 nsfw: this.nsfw,
efc32059
C
638 published: this.createdAt.toISOString(),
639 updated: this.updatedAt.toISOString(),
e4f97bab
C
640 mediaType: 'text/markdown',
641 content: this.getTruncatedDescription(),
642 icon: {
643 type: 'Image',
644 url: getThumbnailUrl(this, baseUrlHttp),
645 mediaType: 'image/jpeg',
646 width: THUMBNAILS_SIZE.width,
647 height: THUMBNAILS_SIZE.height
648 },
16b90975
C
649 url,
650 likes: likesObject,
4e50b6a1
C
651 dislikes: dislikesObject,
652 shares: sharesObject
7b1f49de
C
653 }
654
e4f97bab 655 return videoObject
7b1f49de
C
656}
657
9567011b
C
658getTruncatedDescription = function (this: VideoInstance) {
659 const options = {
660 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
661 }
662
663 return truncate(this.description, options)
664}
665
e4f97bab 666optimizeOriginalVideofile = async function (this: VideoInstance) {
65fcc311 667 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
227d02fe 668 const newExtname = '.mp4'
40298b02 669 const inputVideoFile = this.getOriginalFile()
93e1258c
C
670 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
671 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
227d02fe 672
14d3270f
C
673 const transcodeOptions = {
674 inputPath: videoInputPath,
675 outputPath: videoOutputPath
676 }
677
e4f97bab
C
678 try {
679 // Could be very long!
680 await transcode(transcodeOptions)
14d3270f 681
e4f97bab 682 await unlinkPromise(videoInputPath)
14d3270f 683
e4f97bab
C
684 // Important to do this before getVideoFilename() to take in account the new file extension
685 inputVideoFile.set('extname', newExtname)
686
687 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
688 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
689
690 inputVideoFile.set('size', stats.size)
691
692 await this.createTorrentAndSetInfoHash(inputVideoFile)
693 await inputVideoFile.save()
694
695 } catch (err) {
696 // Auto destruction...
697 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
698
699 throw err
700 }
227d02fe
C
701}
702
e4f97bab 703transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
40298b02
C
704 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
705 const extname = '.mp4'
706
707 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
708 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
709
710 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
711 resolution,
712 extname,
713 size: 0,
714 videoId: this.id
715 })
716 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
14d3270f
C
717
718 const transcodeOptions = {
719 inputPath: videoInputPath,
720 outputPath: videoOutputPath,
721 resolution
722 }
14d3270f 723
e4f97bab
C
724 await transcode(transcodeOptions)
725
726 const stats = await statPromise(videoOutputPath)
727
728 newVideoFile.set('size', stats.size)
729
730 await this.createTorrentAndSetInfoHash(newVideoFile)
731
732 await newVideoFile.save()
733
734 this.VideoFiles.push(newVideoFile)
40298b02
C
735}
736
737getOriginalFileHeight = function (this: VideoInstance) {
738 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
739
14d3270f 740 return getVideoFileHeight(originalFilePath)
40298b02
C
741}
742
9567011b
C
743getDescriptionPath = function (this: VideoInstance) {
744 return `/api/${API_VERSION}/videos/${this.uuid}/description`
745}
746
e4f97bab
C
747getCategoryLabel = function (this: VideoInstance) {
748 let categoryLabel = VIDEO_CATEGORIES[this.category]
749
60862425 750 // Maybe our server is not up to date and there are new categories since our version
e4f97bab
C
751 if (!categoryLabel) categoryLabel = 'Misc'
752
753 return categoryLabel
754}
755
756getLicenceLabel = function (this: VideoInstance) {
757 let licenceLabel = VIDEO_LICENCES[this.licence]
0d0e8dd0 758
60862425 759 // Maybe our server is not up to date and there are new licences since our version
e4f97bab
C
760 if (!licenceLabel) licenceLabel = 'Unknown'
761
762 return licenceLabel
763}
764
765getLanguageLabel = function (this: VideoInstance) {
766 // Language is an optional attribute
767 let languageLabel = VIDEO_LANGUAGES[this.language]
768 if (!languageLabel) languageLabel = 'Unknown'
769
770 return languageLabel
771}
772
93e1258c
C
773removeThumbnail = function (this: VideoInstance) {
774 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
775 return unlinkPromise(thumbnailPath)
776}
777
778removePreview = function (this: VideoInstance) {
779 // Same name than video thumbnail
780 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
781}
782
783removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
784 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
785 return unlinkPromise(filePath)
786}
787
788removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
b0f9f39e
C
789 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
790 return unlinkPromise(torrentPath)
93e1258c
C
791}
792
aaf61f38
C
793// ------------------------------ STATICS ------------------------------
794
6fcd19ba 795list = function () {
93e1258c
C
796 const query = {
797 include: [ Video['sequelize'].models.VideoFile ]
798 }
799
800 return Video.findAll(query)
b769007f
C
801}
802
e71bcc0f 803listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
c46edbc2
C
804 function getRawQuery (select: string) {
805 const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' +
806 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
807 'WHERE "VideoChannel"."accountId" = ' + accountId
808 const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
809 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
810 'WHERE "VideoShare"."accountId" = ' + accountId
811
812 let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
813
814 return rawQuery
815 }
816
817 const rawQuery = getRawQuery('"Video"."id"')
818 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
e71bcc0f
C
819
820 const query = {
821 distinct: true,
822 offset: start,
823 limit: count,
824 order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
825 where: {
826 id: {
827 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
828 }
829 },
830 include: [
831 {
832 model: Video['sequelize'].models.VideoShare,
40ff5707
C
833 required: false,
834 where: {
835 [Sequelize.Op.and]: [
836 {
837 id: {
838 [Sequelize.Op.not]: null
839 }
840 },
841 {
842 accountId
843 }
844 ]
4e50b6a1
C
845 },
846 include: [ Video['sequelize'].models.Account ]
e71bcc0f
C
847 },
848 {
849 model: Video['sequelize'].models.VideoChannel,
850 required: true,
851 include: [
852 {
853 model: Video['sequelize'].models.Account,
854 required: true
855 }
856 ]
857 },
16b90975
C
858 {
859 model: Video['sequelize'].models.AccountVideoRate,
860 include: [ Video['sequelize'].models.Account ]
861 },
862 Video['sequelize'].models.VideoFile,
863 Video['sequelize'].models.Tag
e71bcc0f
C
864 ]
865 }
866
c46edbc2
C
867 return Bluebird.all([
868 Video.findAll(query),
869 Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
870 ]).then(([ rows, totals ]) => {
871 // totals: totalVideos + totalVideoShares
872 let totalVideos = 0
873 let totalVideoShares = 0
874 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
875 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
876
877 const total = totalVideos + totalVideoShares
e71bcc0f
C
878 return {
879 data: rows,
c46edbc2 880 total: total
e71bcc0f
C
881 }
882 })
883}
884
fd45e8f4
C
885listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
886 const query = {
887 distinct: true,
888 offset: start,
889 limit: count,
890 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
891 include: [
892 {
893 model: Video['sequelize'].models.VideoChannel,
894 required: true,
895 include: [
896 {
e4f97bab 897 model: Video['sequelize'].models.Account,
fd45e8f4
C
898 where: {
899 userId
900 },
901 required: true
902 }
903 ]
904 },
905 Video['sequelize'].models.Tag
906 ]
907 }
908
909 return Video.findAndCountAll(query).then(({ rows, count }) => {
910 return {
911 data: rows,
912 total: count
913 }
914 })
915}
916
6fcd19ba 917listForApi = function (start: number, count: number, sort: string) {
feb4bdfd 918 const query = {
e02643f3 919 distinct: true,
feb4bdfd
C
920 offset: start,
921 limit: count,
e02643f3 922 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
923 include: [
924 {
72c7248b 925 model: Video['sequelize'].models.VideoChannel,
8e10cf1a 926 required: true,
72c7248b
C
927 include: [
928 {
e4f97bab 929 model: Video['sequelize'].models.Account,
8e10cf1a 930 required: true,
72c7248b
C
931 include: [
932 {
60862425 933 model: Video['sequelize'].models.Server,
72c7248b
C
934 required: false
935 }
936 ]
937 }
938 ]
7920c273 939 },
fd45e8f4 940 Video['sequelize'].models.Tag
198b205c 941 ],
e02643f3 942 where: createBaseVideosWhere()
feb4bdfd
C
943 }
944
6fcd19ba
C
945 return Video.findAndCountAll(query).then(({ rows, count }) => {
946 return {
947 data: rows,
948 total: count
949 }
feb4bdfd 950 })
aaf61f38
C
951}
952
0a6658fd 953load = function (id: number) {
6fcd19ba 954 return Video.findById(id)
feb4bdfd
C
955}
956
72c7248b
C
957loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
958 const query: Sequelize.FindOptions<VideoAttributes> = {
0a6658fd
C
959 where: {
960 uuid
93e1258c
C
961 },
962 include: [ Video['sequelize'].models.VideoFile ]
a041b171
C
963 }
964
965 if (t !== undefined) query.transaction = t
966
967 return Video.findOne(query)
968}
969
d7d5611c
C
970loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
971 const query: Sequelize.FindOptions<VideoAttributes> = {
972 where: {
973 url
974 },
975 include: [
976 Video['sequelize'].models.VideoFile,
977 {
978 model: Video['sequelize'].models.VideoChannel,
979 include: [ Video['sequelize'].models.Account ]
980 }
981 ]
982 }
983
984 if (t !== undefined) query.transaction = t
985
986 return Video.findOne(query)
987}
988
0d0e8dd0
C
989loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
990 const query: Sequelize.FindOptions<VideoAttributes> = {
991 where: {
992 [Sequelize.Op.or]: [
993 { uuid },
994 { url }
995 ]
996 },
997 include: [ Video['sequelize'].models.VideoFile ]
998 }
999
1000 if (t !== undefined) query.transaction = t
1001
1002 return Video.findOne(query)
1003}
1004
60862425 1005loadAndPopulateAccountAndServerAndTags = function (id: number) {
feb4bdfd 1006 const options = {
df1966c9 1007 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
1008 include: [
1009 {
72c7248b
C
1010 model: Video['sequelize'].models.VideoChannel,
1011 include: [
1012 {
e4f97bab 1013 model: Video['sequelize'].models.Account,
60862425 1014 include: [ { model: Video['sequelize'].models.Server, required: false } ]
72c7248b
C
1015 }
1016 ]
7920c273 1017 },
16b90975
C
1018 {
1019 model: Video['sequelize'].models.AccountVideoRate,
1020 include: [ Video['sequelize'].models.Account ]
1021 },
63c93323
C
1022 {
1023 model: Video['sequelize'].models.VideoShare,
1024 include: [ Video['sequelize'].models.Account ]
1025 },
93e1258c
C
1026 Video['sequelize'].models.Tag,
1027 Video['sequelize'].models.VideoFile
feb4bdfd
C
1028 ]
1029 }
1030
6fcd19ba 1031 return Video.findById(id, options)
aaf61f38
C
1032}
1033
60862425 1034loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
0a6658fd 1035 const options = {
df1966c9 1036 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
0a6658fd
C
1037 where: {
1038 uuid
1039 },
1040 include: [
1041 {
72c7248b
C
1042 model: Video['sequelize'].models.VideoChannel,
1043 include: [
1044 {
e4f97bab 1045 model: Video['sequelize'].models.Account,
60862425 1046 include: [ { model: Video['sequelize'].models.Server, required: false } ]
72c7248b
C
1047 }
1048 ]
0a6658fd 1049 },
16b90975
C
1050 {
1051 model: Video['sequelize'].models.AccountVideoRate,
1052 include: [ Video['sequelize'].models.Account ]
1053 },
63c93323
C
1054 {
1055 model: Video['sequelize'].models.VideoShare,
1056 include: [ Video['sequelize'].models.Account ]
1057 },
93e1258c
C
1058 Video['sequelize'].models.Tag,
1059 Video['sequelize'].models.VideoFile
0a6658fd
C
1060 ]
1061 }
1062
1063 return Video.findOne(options)
1064}
1065
60862425
C
1066searchAndPopulateAccountAndServerAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
1067 const serverInclude: Sequelize.IncludeOptions = {
1068 model: Video['sequelize'].models.Server,
7920c273 1069 required: false
feb4bdfd 1070 }
7920c273 1071
e4f97bab
C
1072 const accountInclude: Sequelize.IncludeOptions = {
1073 model: Video['sequelize'].models.Account,
60862425 1074 include: [ serverInclude ]
72c7248b
C
1075 }
1076
1077 const videoChannelInclude: Sequelize.IncludeOptions = {
1078 model: Video['sequelize'].models.VideoChannel,
e4f97bab 1079 include: [ accountInclude ],
72c7248b 1080 required: true
feb4bdfd
C
1081 }
1082
e6d4b0ff 1083 const tagInclude: Sequelize.IncludeOptions = {
e02643f3 1084 model: Video['sequelize'].models.Tag
7920c273
C
1085 }
1086
556ddc31 1087 const query: Sequelize.FindOptions<VideoAttributes> = {
e02643f3
C
1088 distinct: true,
1089 where: createBaseVideosWhere(),
feb4bdfd
C
1090 offset: start,
1091 limit: count,
e02643f3 1092 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
feb4bdfd
C
1093 }
1094
fd45e8f4 1095 if (field === 'tags') {
e02643f3 1096 const escapedValue = Video['sequelize'].escape('%' + value + '%')
c2962505 1097 query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
6fcd19ba
C
1098 `(SELECT "VideoTags"."videoId"
1099 FROM "Tags"
1100 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
18c8e945 1101 WHERE name ILIKE ${escapedValue}
6fcd19ba 1102 )`
198b205c 1103 )
7920c273 1104 } else if (field === 'host') {
60862425
C
1105 // FIXME: Include our server? (not stored in the database)
1106 serverInclude.where = {
7920c273 1107 host: {
c2962505 1108 [Sequelize.Op.iLike]: '%' + value + '%'
feb4bdfd 1109 }
feb4bdfd 1110 }
60862425 1111 serverInclude.required = true
e4f97bab
C
1112 } else if (field === 'account') {
1113 accountInclude.where = {
7920c273 1114 name: {
c2962505 1115 [Sequelize.Op.iLike]: '%' + value + '%'
feb4bdfd
C
1116 }
1117 }
aaf61f38 1118 } else {
feb4bdfd 1119 query.where[field] = {
c2962505 1120 [Sequelize.Op.iLike]: '%' + value + '%'
feb4bdfd 1121 }
aaf61f38
C
1122 }
1123
7920c273 1124 query.include = [
fd45e8f4 1125 videoChannelInclude, tagInclude
7920c273
C
1126 ]
1127
6fcd19ba
C
1128 return Video.findAndCountAll(query).then(({ rows, count }) => {
1129 return {
1130 data: rows,
1131 total: count
1132 }
feb4bdfd 1133 })
aaf61f38
C
1134}
1135
aaf61f38
C
1136// ---------------------------------------------------------------------------
1137
15d4ee04
C
1138function createBaseVideosWhere () {
1139 return {
1140 id: {
c2962505 1141 [Sequelize.Op.notIn]: Video['sequelize'].literal(
15d4ee04
C
1142 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1143 )
fd45e8f4
C
1144 },
1145 privacy: VideoPrivacy.PUBLIC
15d4ee04
C
1146 }
1147}
a96aed15
C
1148
1149function getBaseUrls (video: VideoInstance) {
1150 let baseUrlHttp
1151 let baseUrlWs
1152
1153 if (video.isOwned()) {
1154 baseUrlHttp = CONFIG.WEBSERVER.URL
1155 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1156 } else {
60862425
C
1157 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1158 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
a96aed15
C
1159 }
1160
1161 return { baseUrlHttp, baseUrlWs }
1162}
1163
e4f97bab
C
1164function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1165 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1166}
1167
a96aed15
C
1168function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1169 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1170}
1171
1172function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1173 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1174}
1175
1176function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1177 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1178 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1179 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1180
1181 const magnetHash = {
1182 xs,
1183 announce,
1184 urlList,
1185 infoHash: videoFile.infoHash,
1186 name: video.name
1187 }
1188
1189 return magnetUtil.encode(magnetHash)
1190}