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