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