]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video.ts
Design follow admin 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,
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
16b90975
C
567 let likesObject
568 let dislikesObject
569
570 if (Array.isArray(this.AccountVideoRates)) {
571 const likes: string[] = []
572 const dislikes: string[] = []
573
574 for (const rate of this.AccountVideoRates) {
575 if (rate.type === 'like') {
576 likes.push(rate.Account.url)
577 } else if (rate.type === 'dislike') {
578 dislikes.push(rate.Account.url)
579 }
580 }
581
582 likesObject = activityPubCollection(likes)
583 dislikesObject = activityPubCollection(dislikes)
584 }
585
4e50b6a1
C
586 let sharesObject
587 if (Array.isArray(this.VideoShares)) {
588 const shares: string[] = []
589
590 for (const videoShare of this.VideoShares) {
591 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
592 shares.push(shareUrl)
593 }
594
595 sharesObject = activityPubCollection(shares)
596 }
597
e4f97bab
C
598 const url = []
599 for (const file of this.VideoFiles) {
600 url.push({
601 type: 'Link',
efc32059 602 mimeType: 'video/' + file.extname.replace('.', ''),
e4f97bab
C
603 url: getVideoFileUrl(this, file, baseUrlHttp),
604 width: file.resolution,
605 size: file.size
606 })
aaf61f38 607
e4f97bab
C
608 url.push({
609 type: 'Link',
610 mimeType: 'application/x-bittorrent',
611 url: getTorrentUrl(this, file, baseUrlHttp),
612 width: file.resolution
93e1258c
C
613 })
614
e4f97bab
C
615 url.push({
616 type: 'Link',
617 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
618 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
619 width: file.resolution
620 })
621 }
aaf61f38 622
165cdc75
C
623 // Add video url too
624 url.push({
625 type: 'Link',
626 mimeType: 'text/html',
627 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
628 })
629
e4f97bab 630 const videoObject: VideoTorrentObject = {
571389d4 631 type: 'Video' as 'Video',
54141398 632 id: this.url,
7b1f49de 633 name: this.name,
e4f97bab
C
634 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
635 duration: 'PT' + this.duration + 'S',
636 uuid: this.uuid,
637 tag,
638 category: {
571389d4
C
639 identifier: this.category + '',
640 name: this.getCategoryLabel()
e4f97bab
C
641 },
642 licence: {
571389d4 643 identifier: this.licence + '',
e4f97bab
C
644 name: this.getLicenceLabel()
645 },
40ff5707 646 language,
d38b8281 647 views: this.views,
e4f97bab 648 nsfw: this.nsfw,
efc32059
C
649 published: this.createdAt.toISOString(),
650 updated: this.updatedAt.toISOString(),
e4f97bab
C
651 mediaType: 'text/markdown',
652 content: this.getTruncatedDescription(),
653 icon: {
654 type: 'Image',
655 url: getThumbnailUrl(this, baseUrlHttp),
656 mediaType: 'image/jpeg',
657 width: THUMBNAILS_SIZE.width,
658 height: THUMBNAILS_SIZE.height
659 },
16b90975
C
660 url,
661 likes: likesObject,
4e50b6a1
C
662 dislikes: dislikesObject,
663 shares: sharesObject
7b1f49de
C
664 }
665
e4f97bab 666 return videoObject
7b1f49de
C
667}
668
9567011b 669getTruncatedDescription = function (this: VideoInstance) {
8e7f08b5
C
670 if (!this.description) return null
671
9567011b
C
672 const options = {
673 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
674 }
675
676 return truncate(this.description, options)
677}
678
e4f97bab 679optimizeOriginalVideofile = async function (this: VideoInstance) {
65fcc311 680 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
227d02fe 681 const newExtname = '.mp4'
40298b02 682 const inputVideoFile = this.getOriginalFile()
93e1258c
C
683 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
684 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
227d02fe 685
14d3270f
C
686 const transcodeOptions = {
687 inputPath: videoInputPath,
688 outputPath: videoOutputPath
689 }
690
e4f97bab
C
691 try {
692 // Could be very long!
693 await transcode(transcodeOptions)
14d3270f 694
e4f97bab 695 await unlinkPromise(videoInputPath)
14d3270f 696
e4f97bab
C
697 // Important to do this before getVideoFilename() to take in account the new file extension
698 inputVideoFile.set('extname', newExtname)
699
700 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
701 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
702
703 inputVideoFile.set('size', stats.size)
704
705 await this.createTorrentAndSetInfoHash(inputVideoFile)
706 await inputVideoFile.save()
707
708 } catch (err) {
709 // Auto destruction...
710 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
711
712 throw err
713 }
227d02fe
C
714}
715
e4f97bab 716transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
40298b02
C
717 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
718 const extname = '.mp4'
719
720 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
721 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
722
723 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
724 resolution,
725 extname,
726 size: 0,
727 videoId: this.id
728 })
729 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
14d3270f
C
730
731 const transcodeOptions = {
732 inputPath: videoInputPath,
733 outputPath: videoOutputPath,
734 resolution
735 }
14d3270f 736
e4f97bab
C
737 await transcode(transcodeOptions)
738
739 const stats = await statPromise(videoOutputPath)
740
741 newVideoFile.set('size', stats.size)
742
743 await this.createTorrentAndSetInfoHash(newVideoFile)
744
745 await newVideoFile.save()
746
747 this.VideoFiles.push(newVideoFile)
40298b02
C
748}
749
750getOriginalFileHeight = function (this: VideoInstance) {
751 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
752
14d3270f 753 return getVideoFileHeight(originalFilePath)
40298b02
C
754}
755
9567011b
C
756getDescriptionPath = function (this: VideoInstance) {
757 return `/api/${API_VERSION}/videos/${this.uuid}/description`
758}
759
e4f97bab
C
760getCategoryLabel = function (this: VideoInstance) {
761 let categoryLabel = VIDEO_CATEGORIES[this.category]
e4f97bab
C
762 if (!categoryLabel) categoryLabel = 'Misc'
763
764 return categoryLabel
765}
766
767getLicenceLabel = function (this: VideoInstance) {
768 let licenceLabel = VIDEO_LICENCES[this.licence]
e4f97bab
C
769 if (!licenceLabel) licenceLabel = 'Unknown'
770
771 return licenceLabel
772}
773
774getLanguageLabel = function (this: VideoInstance) {
e4f97bab
C
775 let languageLabel = VIDEO_LANGUAGES[this.language]
776 if (!languageLabel) languageLabel = 'Unknown'
777
778 return languageLabel
779}
780
93e1258c
C
781removeThumbnail = function (this: VideoInstance) {
782 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
783 return unlinkPromise(thumbnailPath)
784}
785
786removePreview = function (this: VideoInstance) {
787 // Same name than video thumbnail
788 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
789}
790
791removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
792 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
793 return unlinkPromise(filePath)
794}
795
796removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
b0f9f39e
C
797 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
798 return unlinkPromise(torrentPath)
93e1258c
C
799}
800
aaf61f38
C
801// ------------------------------ STATICS ------------------------------
802
6fcd19ba 803list = function () {
93e1258c
C
804 const query = {
805 include: [ Video['sequelize'].models.VideoFile ]
806 }
807
808 return Video.findAll(query)
b769007f
C
809}
810
e71bcc0f 811listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
c46edbc2
C
812 function getRawQuery (select: string) {
813 const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' +
814 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
815 'WHERE "VideoChannel"."accountId" = ' + accountId
816 const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
817 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
818 'WHERE "VideoShare"."accountId" = ' + accountId
819
820 let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
821
822 return rawQuery
823 }
824
825 const rawQuery = getRawQuery('"Video"."id"')
826 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
e71bcc0f
C
827
828 const query = {
829 distinct: true,
830 offset: start,
831 limit: count,
832 order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
833 where: {
834 id: {
835 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
836 }
837 },
838 include: [
839 {
840 model: Video['sequelize'].models.VideoShare,
40ff5707
C
841 required: false,
842 where: {
843 [Sequelize.Op.and]: [
844 {
845 id: {
846 [Sequelize.Op.not]: null
847 }
848 },
849 {
850 accountId
851 }
852 ]
4e50b6a1
C
853 },
854 include: [ Video['sequelize'].models.Account ]
e71bcc0f
C
855 },
856 {
857 model: Video['sequelize'].models.VideoChannel,
858 required: true,
859 include: [
860 {
861 model: Video['sequelize'].models.Account,
862 required: true
863 }
864 ]
865 },
16b90975
C
866 {
867 model: Video['sequelize'].models.AccountVideoRate,
868 include: [ Video['sequelize'].models.Account ]
869 },
870 Video['sequelize'].models.VideoFile,
871 Video['sequelize'].models.Tag
e71bcc0f
C
872 ]
873 }
874
c46edbc2
C
875 return Bluebird.all([
876 Video.findAll(query),
877 Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
878 ]).then(([ rows, totals ]) => {
879 // totals: totalVideos + totalVideoShares
880 let totalVideos = 0
881 let totalVideoShares = 0
882 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
883 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
884
885 const total = totalVideos + totalVideoShares
e71bcc0f
C
886 return {
887 data: rows,
c46edbc2 888 total: total
e71bcc0f
C
889 }
890 })
891}
892
fd45e8f4
C
893listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
894 const query = {
895 distinct: true,
896 offset: start,
897 limit: count,
898 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
899 include: [
900 {
901 model: Video['sequelize'].models.VideoChannel,
902 required: true,
903 include: [
904 {
e4f97bab 905 model: Video['sequelize'].models.Account,
fd45e8f4
C
906 where: {
907 userId
908 },
909 required: true
910 }
911 ]
912 },
913 Video['sequelize'].models.Tag
914 ]
915 }
916
917 return Video.findAndCountAll(query).then(({ rows, count }) => {
918 return {
919 data: rows,
920 total: count
921 }
922 })
923}
924
6fcd19ba 925listForApi = function (start: number, count: number, sort: string) {
feb4bdfd 926 const query = {
e02643f3 927 distinct: true,
feb4bdfd
C
928 offset: start,
929 limit: count,
e02643f3 930 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
931 include: [
932 {
72c7248b 933 model: Video['sequelize'].models.VideoChannel,
8e10cf1a 934 required: true,
72c7248b
C
935 include: [
936 {
e4f97bab 937 model: Video['sequelize'].models.Account,
8e10cf1a 938 required: true,
72c7248b
C
939 include: [
940 {
60862425 941 model: Video['sequelize'].models.Server,
72c7248b
C
942 required: false
943 }
944 ]
945 }
946 ]
7920c273 947 },
fd45e8f4 948 Video['sequelize'].models.Tag
198b205c 949 ],
e02643f3 950 where: createBaseVideosWhere()
feb4bdfd
C
951 }
952
6fcd19ba
C
953 return Video.findAndCountAll(query).then(({ rows, count }) => {
954 return {
955 data: rows,
956 total: count
957 }
feb4bdfd 958 })
aaf61f38
C
959}
960
0a6658fd 961load = function (id: number) {
6fcd19ba 962 return Video.findById(id)
feb4bdfd
C
963}
964
72c7248b
C
965loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
966 const query: Sequelize.FindOptions<VideoAttributes> = {
0a6658fd
C
967 where: {
968 uuid
93e1258c
C
969 },
970 include: [ Video['sequelize'].models.VideoFile ]
a041b171
C
971 }
972
973 if (t !== undefined) query.transaction = t
974
975 return Video.findOne(query)
976}
977
d7d5611c
C
978loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
979 const query: Sequelize.FindOptions<VideoAttributes> = {
980 where: {
981 url
982 },
983 include: [
984 Video['sequelize'].models.VideoFile,
985 {
986 model: Video['sequelize'].models.VideoChannel,
987 include: [ Video['sequelize'].models.Account ]
988 }
989 ]
990 }
991
992 if (t !== undefined) query.transaction = t
993
994 return Video.findOne(query)
995}
996
0d0e8dd0
C
997loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
998 const query: Sequelize.FindOptions<VideoAttributes> = {
999 where: {
1000 [Sequelize.Op.or]: [
1001 { uuid },
1002 { url }
1003 ]
1004 },
1005 include: [ Video['sequelize'].models.VideoFile ]
1006 }
1007
1008 if (t !== undefined) query.transaction = t
1009
1010 return Video.findOne(query)
1011}
1012
60862425 1013loadAndPopulateAccountAndServerAndTags = function (id: number) {
feb4bdfd 1014 const options = {
df1966c9 1015 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
1016 include: [
1017 {
72c7248b
C
1018 model: Video['sequelize'].models.VideoChannel,
1019 include: [
1020 {
e4f97bab 1021 model: Video['sequelize'].models.Account,
60862425 1022 include: [ { model: Video['sequelize'].models.Server, required: false } ]
72c7248b
C
1023 }
1024 ]
7920c273 1025 },
16b90975
C
1026 {
1027 model: Video['sequelize'].models.AccountVideoRate,
1028 include: [ Video['sequelize'].models.Account ]
1029 },
63c93323
C
1030 {
1031 model: Video['sequelize'].models.VideoShare,
1032 include: [ Video['sequelize'].models.Account ]
1033 },
93e1258c
C
1034 Video['sequelize'].models.Tag,
1035 Video['sequelize'].models.VideoFile
feb4bdfd
C
1036 ]
1037 }
1038
6fcd19ba 1039 return Video.findById(id, options)
aaf61f38
C
1040}
1041
60862425 1042loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
0a6658fd 1043 const options = {
df1966c9 1044 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
0a6658fd
C
1045 where: {
1046 uuid
1047 },
1048 include: [
1049 {
72c7248b
C
1050 model: Video['sequelize'].models.VideoChannel,
1051 include: [
1052 {
e4f97bab 1053 model: Video['sequelize'].models.Account,
60862425 1054 include: [ { model: Video['sequelize'].models.Server, required: false } ]
72c7248b
C
1055 }
1056 ]
0a6658fd 1057 },
16b90975
C
1058 {
1059 model: Video['sequelize'].models.AccountVideoRate,
1060 include: [ Video['sequelize'].models.Account ]
1061 },
63c93323
C
1062 {
1063 model: Video['sequelize'].models.VideoShare,
1064 include: [ Video['sequelize'].models.Account ]
1065 },
93e1258c
C
1066 Video['sequelize'].models.Tag,
1067 Video['sequelize'].models.VideoFile
0a6658fd
C
1068 ]
1069 }
1070
1071 return Video.findOne(options)
1072}
1073
f3aaa9a9 1074searchAndPopulateAccountAndServerAndTags = function (value: string, start: number, count: number, sort: string) {
60862425
C
1075 const serverInclude: Sequelize.IncludeOptions = {
1076 model: Video['sequelize'].models.Server,
7920c273 1077 required: false
feb4bdfd 1078 }
7920c273 1079
e4f97bab
C
1080 const accountInclude: Sequelize.IncludeOptions = {
1081 model: Video['sequelize'].models.Account,
60862425 1082 include: [ serverInclude ]
72c7248b
C
1083 }
1084
1085 const videoChannelInclude: Sequelize.IncludeOptions = {
1086 model: Video['sequelize'].models.VideoChannel,
e4f97bab 1087 include: [ accountInclude ],
72c7248b 1088 required: true
feb4bdfd
C
1089 }
1090
e6d4b0ff 1091 const tagInclude: Sequelize.IncludeOptions = {
e02643f3 1092 model: Video['sequelize'].models.Tag
7920c273
C
1093 }
1094
556ddc31 1095 const query: Sequelize.FindOptions<VideoAttributes> = {
e02643f3
C
1096 distinct: true,
1097 where: createBaseVideosWhere(),
feb4bdfd
C
1098 offset: start,
1099 limit: count,
e02643f3 1100 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
feb4bdfd
C
1101 }
1102
f3aaa9a9
C
1103 // TODO: search on tags too
1104 // const escapedValue = Video['sequelize'].escape('%' + value + '%')
1105 // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
1106 // `(SELECT "VideoTags"."videoId"
1107 // FROM "Tags"
1108 // INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1109 // WHERE name ILIKE ${escapedValue}
1110 // )`
1111 // )
1112
1113 // TODO: search on account too
1114 // accountInclude.where = {
1115 // name: {
1116 // [Sequelize.Op.iLike]: '%' + value + '%'
1117 // }
1118 // }
1119 query.where['name'] = {
1120 [Sequelize.Op.iLike]: '%' + value + '%'
aaf61f38
C
1121 }
1122
7920c273 1123 query.include = [
fd45e8f4 1124 videoChannelInclude, tagInclude
7920c273
C
1125 ]
1126
6fcd19ba
C
1127 return Video.findAndCountAll(query).then(({ rows, count }) => {
1128 return {
1129 data: rows,
1130 total: count
1131 }
feb4bdfd 1132 })
aaf61f38
C
1133}
1134
aaf61f38
C
1135// ---------------------------------------------------------------------------
1136
15d4ee04
C
1137function createBaseVideosWhere () {
1138 return {
1139 id: {
c2962505 1140 [Sequelize.Op.notIn]: Video['sequelize'].literal(
15d4ee04
C
1141 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1142 )
fd45e8f4
C
1143 },
1144 privacy: VideoPrivacy.PUBLIC
15d4ee04
C
1145 }
1146}
a96aed15
C
1147
1148function getBaseUrls (video: VideoInstance) {
1149 let baseUrlHttp
1150 let baseUrlWs
1151
1152 if (video.isOwned()) {
1153 baseUrlHttp = CONFIG.WEBSERVER.URL
1154 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1155 } else {
60862425
C
1156 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1157 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
a96aed15
C
1158 }
1159
1160 return { baseUrlHttp, baseUrlWs }
1161}
1162
e4f97bab
C
1163function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1164 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1165}
1166
a96aed15
C
1167function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1168 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1169}
1170
1171function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1172 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1173}
1174
1175function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1176 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1177 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1178 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1179
1180 const magnetHash = {
1181 xs,
1182 announce,
1183 urlList,
1184 infoHash: videoFile.infoHash,
1185 name: video.name
1186 }
1187
1188 return magnetUtil.encode(magnetHash)
1189}