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