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