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