aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-02-11 11:52:34 +0100
committerChocobozzz <me@florianbigard.com>2019-02-11 11:52:34 +0100
commit88108880bbdba473cfe36ecbebc1c3c4f972e102 (patch)
treeb242efb3b4f0d7e49d88f2d1f2063b5b3b0489c0 /server/models/video
parent53a94c7cfa8368da4cd248d65df8346905938f0c (diff)
parent9b712a2017e4ab3cf12cd6bd58278905520159d0 (diff)
downloadPeerTube-88108880bbdba473cfe36ecbebc1c3c4f972e102.tar.gz
PeerTube-88108880bbdba473cfe36ecbebc1c3c4f972e102.tar.zst
PeerTube-88108880bbdba473cfe36ecbebc1c3c4f972e102.zip
Merge branch 'develop' into pr/1217
Diffstat (limited to 'server/models/video')
-rw-r--r--server/models/video/video-abuse.ts24
-rw-r--r--server/models/video/video-blacklist.ts31
-rw-r--r--server/models/video/video-channel.ts25
-rw-r--r--server/models/video/video-comment.ts140
-rw-r--r--server/models/video/video-file.ts32
-rw-r--r--server/models/video/video-format-utils.ts69
-rw-r--r--server/models/video/video-import.ts4
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts158
-rw-r--r--server/models/video/video.ts372
10 files changed, 715 insertions, 142 deletions
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index dbb88ca45..cc47644f2 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -1,17 +1,4 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' 2import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
16import { VideoAbuse } from '../../../shared/models/videos' 3import { VideoAbuse } from '../../../shared/models/videos'
17import { 4import {
@@ -19,7 +6,6 @@ import {
19 isVideoAbuseReasonValid, 6 isVideoAbuseReasonValid,
20 isVideoAbuseStateValid 7 isVideoAbuseStateValid
21} from '../../helpers/custom-validators/video-abuses' 8} from '../../helpers/custom-validators/video-abuses'
22import { Emailer } from '../../lib/emailer'
23import { AccountModel } from '../account/account' 9import { AccountModel } from '../account/account'
24import { getSort, throwIfNotValid } from '../utils' 10import { getSort, throwIfNotValid } from '../utils'
25import { VideoModel } from './video' 11import { VideoModel } from './video'
@@ -40,8 +26,9 @@ import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers'
40export class VideoAbuseModel extends Model<VideoAbuseModel> { 26export class VideoAbuseModel extends Model<VideoAbuseModel> {
41 27
42 @AllowNull(false) 28 @AllowNull(false)
29 @Default(null)
43 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason')) 30 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
44 @Column 31 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
45 reason: string 32 reason: string
46 33
47 @AllowNull(false) 34 @AllowNull(false)
@@ -86,11 +73,6 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
86 }) 73 })
87 Video: VideoModel 74 Video: VideoModel
88 75
89 @AfterCreate
90 static sendEmailNotification (instance: VideoAbuseModel) {
91 return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
92 }
93
94 static loadByIdAndVideoId (id: number, videoId: number) { 76 static loadByIdAndVideoId (id: number, videoId: number) {
95 const query = { 77 const query = {
96 where: { 78 where: {
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 67f7cd487..3b567e488 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,21 +1,7 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AfterDestroy,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
16import { VideoModel } from './video' 3import { VideoModel } from './video'
17import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 4import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist'
18import { Emailer } from '../../lib/emailer'
19import { VideoBlacklist } from '../../../shared/models/videos' 5import { VideoBlacklist } from '../../../shared/models/videos'
20import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { CONSTRAINTS_FIELDS } from '../../initializers'
21 7
@@ -35,6 +21,10 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
35 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max)) 21 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
36 reason: string 22 reason: string
37 23
24 @AllowNull(false)
25 @Column
26 unfederated: boolean
27
38 @CreatedAt 28 @CreatedAt
39 createdAt: Date 29 createdAt: Date
40 30
@@ -53,16 +43,6 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
53 }) 43 })
54 Video: VideoModel 44 Video: VideoModel
55 45
56 @AfterCreate
57 static sendBlacklistEmailNotification (instance: VideoBlacklistModel) {
58 return Emailer.Instance.addVideoBlacklistReportJob(instance.videoId, instance.reason)
59 }
60
61 @AfterDestroy
62 static sendUnblacklistEmailNotification (instance: VideoBlacklistModel) {
63 return Emailer.Instance.addVideoUnblacklistReportJob(instance.videoId)
64 }
65
66 static listForApi (start: number, count: number, sort: SortType) { 46 static listForApi (start: number, count: number, sort: SortType) {
67 const query = { 47 const query = {
68 offset: start, 48 offset: start,
@@ -103,6 +83,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
103 createdAt: this.createdAt, 83 createdAt: this.createdAt,
104 updatedAt: this.updatedAt, 84 updatedAt: this.updatedAt,
105 reason: this.reason, 85 reason: this.reason,
86 unfederated: this.unfederated,
106 87
107 video: { 88 video: {
108 id: video.id, 89 id: video.id,
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index f4586917e..5598d80f6 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -233,6 +233,27 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
233 }) 233 })
234 } 234 }
235 235
236 static listLocalsForSitemap (sort: string) {
237 const query = {
238 attributes: [ ],
239 offset: 0,
240 order: getSort(sort),
241 include: [
242 {
243 attributes: [ 'preferredUsername', 'serverId' ],
244 model: ActorModel.unscoped(),
245 where: {
246 serverId: null
247 }
248 }
249 ]
250 }
251
252 return VideoChannelModel
253 .unscoped()
254 .findAll(query)
255 }
256
236 static searchForApi (options: { 257 static searchForApi (options: {
237 actorId: number 258 actorId: number
238 search: string 259 search: string
@@ -449,4 +470,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
449 getDisplayName () { 470 getDisplayName () {
450 return this.name 471 return this.name
451 } 472 }
473
474 isOutdated () {
475 return this.Actor.isOutdated()
476 }
452} 477}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index f84c1880c..cf6278da7 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,21 +1,37 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { 2import {
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table, 3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 IFindOptions,
11 Is,
12 Model,
13 Scopes,
14 Table,
4 UpdatedAt 15 UpdatedAt
5} from 'sequelize-typescript' 16} from 'sequelize-typescript'
6import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects' 17import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
7import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
8import { VideoComment } from '../../../shared/models/videos/video-comment.model' 19import { VideoComment } from '../../../shared/models/videos/video-comment.model'
9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10import { CONSTRAINTS_FIELDS } from '../../initializers' 21import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
11import { sendDeleteVideoComment } from '../../lib/activitypub/send' 22import { sendDeleteVideoComment } from '../../lib/activitypub/send'
12import { AccountModel } from '../account/account' 23import { AccountModel } from '../account/account'
13import { ActorModel } from '../activitypub/actor' 24import { ActorModel } from '../activitypub/actor'
14import { AvatarModel } from '../avatar/avatar' 25import { AvatarModel } from '../avatar/avatar'
15import { ServerModel } from '../server/server' 26import { ServerModel } from '../server/server'
16import { getSort, throwIfNotValid } from '../utils' 27import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
17import { VideoModel } from './video' 28import { VideoModel } from './video'
18import { VideoChannelModel } from './video-channel' 29import { VideoChannelModel } from './video-channel'
30import { getServerActor } from '../../helpers/utils'
31import { UserModel } from '../account/user'
32import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33import { regexpCapture } from '../../helpers/regexp'
34import { uniq } from 'lodash'
19 35
20enum ScopeNames { 36enum ScopeNames {
21 WITH_ACCOUNT = 'WITH_ACCOUNT', 37 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -25,18 +41,29 @@ enum ScopeNames {
25} 41}
26 42
27@Scopes({ 43@Scopes({
28 [ScopeNames.ATTRIBUTES_FOR_API]: { 44 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
29 attributes: { 45 return {
30 include: [ 46 attributes: {
31 [ 47 include: [
32 Sequelize.literal( 48 [
33 '(SELECT COUNT("replies"."id") ' + 49 Sequelize.literal(
34 'FROM "videoComment" AS "replies" ' + 50 '(' +
35 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")' 51 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
36 ), 52 'SELECT COUNT("replies"."id") - (' +
37 'totalReplies' 53 'SELECT COUNT("replies"."id") ' +
54 'FROM "videoComment" AS "replies" ' +
55 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
56 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
57 ')' +
58 'FROM "videoComment" AS "replies" ' +
59 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
60 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
61 ')'
62 ),
63 'totalReplies'
64 ]
38 ] 65 ]
39 ] 66 }
40 } 67 }
41 }, 68 },
42 [ScopeNames.WITH_ACCOUNT]: { 69 [ScopeNames.WITH_ACCOUNT]: {
@@ -267,26 +294,47 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
267 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query) 294 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
268 } 295 }
269 296
270 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) { 297 static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
298 const serverActor = await getServerActor()
299 const serverAccountId = serverActor.Account.id
300 const userAccountId = user ? user.Account.id : undefined
301
271 const query = { 302 const query = {
272 offset: start, 303 offset: start,
273 limit: count, 304 limit: count,
274 order: getSort(sort), 305 order: getSort(sort),
275 where: { 306 where: {
276 videoId, 307 videoId,
277 inReplyToCommentId: null 308 inReplyToCommentId: null,
309 accountId: {
310 [Sequelize.Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
312 )
313 }
278 } 314 }
279 } 315 }
280 316
317 // FIXME: typings
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT,
320 {
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
322 }
323 ]
324
281 return VideoCommentModel 325 return VideoCommentModel
282 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) 326 .scope(scopes)
283 .findAndCountAll(query) 327 .findAndCountAll(query)
284 .then(({ rows, count }) => { 328 .then(({ rows, count }) => {
285 return { total: count, data: rows } 329 return { total: count, data: rows }
286 }) 330 })
287 } 331 }
288 332
289 static listThreadCommentsForApi (videoId: number, threadId: number) { 333 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
334 const serverActor = await getServerActor()
335 const serverAccountId = serverActor.Account.id
336 const userAccountId = user ? user.Account.id : undefined
337
290 const query = { 338 const query = {
291 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ], 339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
292 where: { 340 where: {
@@ -294,12 +342,24 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
294 [ Sequelize.Op.or ]: [ 342 [ Sequelize.Op.or ]: [
295 { id: threadId }, 343 { id: threadId },
296 { originCommentId: threadId } 344 { originCommentId: threadId }
297 ] 345 ],
346 accountId: {
347 [Sequelize.Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
349 )
350 }
298 } 351 }
299 } 352 }
300 353
354 const scopes: any[] = [
355 ScopeNames.WITH_ACCOUNT,
356 {
357 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
358 }
359 ]
360
301 return VideoCommentModel 361 return VideoCommentModel
302 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) 362 .scope(scopes)
303 .findAndCountAll(query) 363 .findAndCountAll(query)
304 .then(({ rows, count }) => { 364 .then(({ rows, count }) => {
305 return { total: count, data: rows } 365 return { total: count, data: rows }
@@ -313,9 +373,11 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
313 id: { 373 id: {
314 [ Sequelize.Op.in ]: Sequelize.literal('(' + 374 [ Sequelize.Op.in ]: Sequelize.literal('(' +
315 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
316 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' + 376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
317 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' + 377 'UNION ' +
318 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' + 378 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
379 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
380 ') ' +
319 'SELECT id FROM children' + 381 'SELECT id FROM children' +
320 ')'), 382 ')'),
321 [ Sequelize.Op.ne ]: comment.id 383 [ Sequelize.Op.ne ]: comment.id
@@ -391,6 +453,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
391 } 453 }
392 } 454 }
393 455
456 getCommentStaticPath () {
457 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
458 }
459
394 getThreadId (): number { 460 getThreadId (): number {
395 return this.originCommentId || this.id 461 return this.originCommentId || this.id
396 } 462 }
@@ -399,6 +465,34 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
399 return this.Account.isOwned() 465 return this.Account.isOwned()
400 } 466 }
401 467
468 extractMentions () {
469 if (!this.text) return []
470
471 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
473
474 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
475 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
476 const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
477 const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
478
479 return uniq(
480 [].concat(
481 regexpCapture(this.text, remoteMentionsRegex)
482 .map(([ , username ]) => username),
483
484 regexpCapture(this.text, localMentionsRegex)
485 .map(([ , username ]) => username),
486
487 regexpCapture(this.text, firstMentionRegex)
488 .map(([ , username1, username2 ]) => username1 || username2),
489
490 regexpCapture(this.text, endMentionRegex)
491 .map(([ , username1, username2 ]) => username1 || username2)
492 )
493 )
494 }
495
402 toFormattedJSON () { 496 toFormattedJSON () {
403 return { 497 return {
404 id: this.id, 498 id: this.id,
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index adebdf0c7..7d1e371b9 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -1,4 +1,3 @@
1import { values } from 'lodash'
2import { 1import {
3 AllowNull, 2 AllowNull,
4 BelongsTo, 3 BelongsTo,
@@ -14,12 +13,12 @@ import {
14 UpdatedAt 13 UpdatedAt
15} from 'sequelize-typescript' 14} from 'sequelize-typescript'
16import { 15import {
16 isVideoFileExtnameValid,
17 isVideoFileInfoHashValid, 17 isVideoFileInfoHashValid,
18 isVideoFileResolutionValid, 18 isVideoFileResolutionValid,
19 isVideoFileSizeValid, 19 isVideoFileSizeValid,
20 isVideoFPSResolutionValid 20 isVideoFPSResolutionValid
21} from '../../helpers/custom-validators/videos' 21} from '../../helpers/custom-validators/videos'
22import { CONSTRAINTS_FIELDS } from '../../initializers'
23import { throwIfNotValid } from '../utils' 22import { throwIfNotValid } from '../utils'
24import { VideoModel } from './video' 23import { VideoModel } from './video'
25import * as Sequelize from 'sequelize' 24import * as Sequelize from 'sequelize'
@@ -58,11 +57,12 @@ export class VideoFileModel extends Model<VideoFileModel> {
58 size: number 57 size: number
59 58
60 @AllowNull(false) 59 @AllowNull(false)
61 @Column(DataType.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME))) 60 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
61 @Column
62 extname: string 62 extname: string
63 63
64 @AllowNull(false) 64 @AllowNull(false)
65 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 65 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
66 @Column 66 @Column
67 infoHash: string 67 infoHash: string
68 68
@@ -86,14 +86,14 @@ export class VideoFileModel extends Model<VideoFileModel> {
86 86
87 @HasMany(() => VideoRedundancyModel, { 87 @HasMany(() => VideoRedundancyModel, {
88 foreignKey: { 88 foreignKey: {
89 allowNull: false 89 allowNull: true
90 }, 90 },
91 onDelete: 'CASCADE', 91 onDelete: 'CASCADE',
92 hooks: true 92 hooks: true
93 }) 93 })
94 RedundancyVideos: VideoRedundancyModel[] 94 RedundancyVideos: VideoRedundancyModel[]
95 95
96 static isInfohashExists (infoHash: string) { 96 static doesInfohashExist (infoHash: string) {
97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
98 const options = { 98 const options = {
99 type: Sequelize.QueryTypes.SELECT, 99 type: Sequelize.QueryTypes.SELECT,
@@ -120,6 +120,26 @@ export class VideoFileModel extends Model<VideoFileModel> {
120 return VideoFileModel.findById(id, options) 120 return VideoFileModel.findById(id, options)
121 } 121 }
122 122
123 static async getStats () {
124 let totalLocalVideoFilesSize = await VideoFileModel.sum('size', {
125 include: [
126 {
127 attributes: [],
128 model: VideoModel.unscoped(),
129 where: {
130 remote: false
131 }
132 }
133 ]
134 } as any)
135 // Sequelize could return null...
136 if (!totalLocalVideoFilesSize) totalLocalVideoFilesSize = 0
137
138 return {
139 totalLocalVideoFilesSize
140 }
141 }
142
123 hasSameUniqueKeysThan (other: VideoFileModel) { 143 hasSameUniqueKeysThan (other: VideoFileModel) {
124 return this.fps === other.fps && 144 return this.fps === other.fps &&
125 this.resolution === other.resolution && 145 this.resolution === other.resolution &&
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index e7bff2ed7..76d0445d4 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -1,8 +1,13 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video' 2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file' 3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' 4import {
5import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers' 5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
10import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
7import { 12import {
8 getVideoCommentsActivityPubUrl, 13 getVideoCommentsActivityPubUrl,
@@ -11,6 +16,8 @@ import {
11 getVideoSharesActivityPubUrl 16 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub' 17} from '../../lib/activitypub'
13import { isArray } from '../../helpers/custom-validators/misc' 18import { isArray } from '../../helpers/custom-validators/misc'
19import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
20import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
14 21
15export type VideoFormattingJSONOptions = { 22export type VideoFormattingJSONOptions = {
16 completeDescription?: boolean 23 completeDescription?: boolean
@@ -120,7 +127,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
120 } 127 }
121 }) 128 })
122 129
130 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
131
123 const tags = video.Tags ? video.Tags.map(t => t.name) : [] 132 const tags = video.Tags ? video.Tags.map(t => t.name) : []
133
134 const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
135
124 const detailsJson = { 136 const detailsJson = {
125 support: video.support, 137 support: video.support,
126 descriptionPath: video.getDescriptionAPIPath(), 138 descriptionPath: video.getDescriptionAPIPath(),
@@ -134,7 +146,11 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
134 id: video.state, 146 id: video.state,
135 label: VideoModel.getStateLabel(video.state) 147 label: VideoModel.getStateLabel(video.state)
136 }, 148 },
137 files: [] 149
150 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
151
152 files: [],
153 streamingPlaylists
138 } 154 }
139 155
140 // Format and sort video files 156 // Format and sort video files
@@ -143,6 +159,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
143 return Object.assign(formattedJson, detailsJson) 159 return Object.assign(formattedJson, detailsJson)
144} 160}
145 161
162function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
163 if (isArray(playlists) === false) return []
164
165 return playlists
166 .map(playlist => {
167 const redundancies = isArray(playlist.RedundancyVideos)
168 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
169 : []
170
171 return {
172 id: playlist.id,
173 type: playlist.type,
174 playlistUrl: playlist.playlistUrl,
175 segmentsSha256Url: playlist.segmentsSha256Url,
176 redundancies
177 } as VideoStreamingPlaylist
178 })
179}
180
146function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 181function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
147 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 182 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
148 183
@@ -208,7 +243,8 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
208 for (const file of video.VideoFiles) { 243 for (const file of video.VideoFiles) {
209 url.push({ 244 url.push({
210 type: 'Link', 245 type: 'Link',
211 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any, 246 mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
247 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
212 href: video.getVideoFileUrl(file, baseUrlHttp), 248 href: video.getVideoFileUrl(file, baseUrlHttp),
213 height: file.resolution, 249 height: file.resolution,
214 size: file.size, 250 size: file.size,
@@ -218,6 +254,7 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
218 url.push({ 254 url.push({
219 type: 'Link', 255 type: 'Link',
220 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent', 256 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
257 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
221 href: video.getTorrentUrl(file, baseUrlHttp), 258 href: video.getTorrentUrl(file, baseUrlHttp),
222 height: file.resolution 259 height: file.resolution
223 }) 260 })
@@ -225,15 +262,39 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
225 url.push({ 262 url.push({
226 type: 'Link', 263 type: 'Link',
227 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', 264 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
265 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
228 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs), 266 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
229 height: file.resolution 267 height: file.resolution
230 }) 268 })
231 } 269 }
232 270
271 for (const playlist of (video.VideoStreamingPlaylists || [])) {
272 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
273
274 tag = playlist.p2pMediaLoaderInfohashes
275 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
276 tag.push({
277 type: 'Link',
278 name: 'sha256',
279 mimeType: 'application/json' as 'application/json',
280 mediaType: 'application/json' as 'application/json',
281 href: playlist.segmentsSha256Url
282 })
283
284 url.push({
285 type: 'Link',
286 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
287 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
288 href: playlist.playlistUrl,
289 tag
290 })
291 }
292
233 // Add video url too 293 // Add video url too
234 url.push({ 294 url.push({
235 type: 'Link', 295 type: 'Link',
236 mimeType: 'text/html', 296 mimeType: 'text/html',
297 mediaType: 'text/html',
237 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 298 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
238 }) 299 })
239 300
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index 8d442b3f8..c723e57c0 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -144,6 +144,10 @@ export class VideoImportModel extends Model<VideoImportModel> {
144 }) 144 })
145 } 145 }
146 146
147 getTargetIdentifier () {
148 return this.targetUrl || this.magnetUri || this.torrentName
149 }
150
147 toFormattedJSON (): VideoImport { 151 toFormattedJSON (): VideoImport {
148 const videoFormatOptions = { 152 const videoFormatOptions = {
149 completeDescription: true, 153 completeDescription: true,
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index fa9a70d50..c87f71277 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -88,7 +88,7 @@ export class VideoShareModel extends Model<VideoShareModel> {
88 }) 88 })
89 Video: VideoModel 89 Video: VideoModel
90 90
91 static load (actorId: number, videoId: number, t: Sequelize.Transaction) { 91 static load (actorId: number, videoId: number, t?: Sequelize.Transaction) {
92 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({ 92 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
93 where: { 93 where: {
94 actorId, 94 actorId,
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644
index 000000000..bf6f7b0c4
--- /dev/null
+++ b/server/models/video/video-streaming-playlist.ts
@@ -0,0 +1,158 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
3import { throwIfNotValid } from '../utils'
4import { VideoModel } from './video'
5import * as Sequelize from 'sequelize'
6import { VideoRedundancyModel } from '../redundancy/video-redundancy'
7import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9import { CONSTRAINTS_FIELDS, STATIC_PATHS } from '../../initializers'
10import { VideoFileModel } from './video-file'
11import { join } from 'path'
12import { sha1 } from '../../helpers/core-utils'
13import { isArrayOf } from '../../helpers/custom-validators/misc'
14
15@Table({
16 tableName: 'videoStreamingPlaylist',
17 indexes: [
18 {
19 fields: [ 'videoId' ]
20 },
21 {
22 fields: [ 'videoId', 'type' ],
23 unique: true
24 },
25 {
26 fields: [ 'p2pMediaLoaderInfohashes' ],
27 using: 'gin'
28 }
29 ]
30})
31export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistModel> {
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @AllowNull(false)
39 @Column
40 type: VideoStreamingPlaylistType
41
42 @AllowNull(false)
43 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url'))
44 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
45 playlistUrl: string
46
47 @AllowNull(false)
48 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
49 @Column(DataType.ARRAY(DataType.STRING))
50 p2pMediaLoaderInfohashes: string[]
51
52 @AllowNull(false)
53 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
54 @Column
55 segmentsSha256Url: string
56
57 @ForeignKey(() => VideoModel)
58 @Column
59 videoId: number
60
61 @BelongsTo(() => VideoModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'CASCADE'
66 })
67 Video: VideoModel
68
69 @HasMany(() => VideoRedundancyModel, {
70 foreignKey: {
71 allowNull: false
72 },
73 onDelete: 'CASCADE',
74 hooks: true
75 })
76 RedundancyVideos: VideoRedundancyModel[]
77
78 static doesInfohashExist (infoHash: string) {
79 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
80 const options = {
81 type: Sequelize.QueryTypes.SELECT,
82 bind: { infoHash },
83 raw: true
84 }
85
86 return VideoModel.sequelize.query(query, options)
87 .then(results => {
88 return results.length === 1
89 })
90 }
91
92 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
93 const hashes: string[] = []
94
95 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L97
96 for (let i = 0; i < videoFiles.length; i++) {
97 hashes.push(sha1(`1${playlistUrl}+V${i}`))
98 }
99
100 return hashes
101 }
102
103 static loadWithVideo (id: number) {
104 const options = {
105 include: [
106 {
107 model: VideoModel.unscoped(),
108 required: true
109 }
110 ]
111 }
112
113 return VideoStreamingPlaylistModel.findById(id, options)
114 }
115
116 static getHlsPlaylistFilename (resolution: number) {
117 return resolution + '.m3u8'
118 }
119
120 static getMasterHlsPlaylistFilename () {
121 return 'master.m3u8'
122 }
123
124 static getHlsSha256SegmentsFilename () {
125 return 'segments-sha256.json'
126 }
127
128 static getHlsVideoName (uuid: string, resolution: number) {
129 return `${uuid}-${resolution}-fragmented.mp4`
130 }
131
132 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
133 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
134 }
135
136 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
137 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
138 }
139
140 static getHlsSha256SegmentsStaticPath (videoUUID: string) {
141 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
142 }
143
144 getStringType () {
145 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
146
147 return 'unknown'
148 }
149
150 getVideoRedundancyUrl (baseUrlHttp: string) {
151 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
152 }
153
154 hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
155 return this.type === other.type &&
156 this.videoId === other.videoId
157 }
158}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index a9baaf1da..0feeed4f8 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -27,7 +27,7 @@ import {
27 Table, 27 Table,
28 UpdatedAt 28 UpdatedAt
29} from 'sequelize-typescript' 29} from 'sequelize-typescript'
30import { VideoPrivacy, VideoState } from '../../../shared' 30import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
33import { VideoFilter } from '../../../shared/models/videos/video-query.type' 33import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -52,7 +52,7 @@ import {
52 ACTIVITY_PUB, 52 ACTIVITY_PUB,
53 API_VERSION, 53 API_VERSION,
54 CONFIG, 54 CONFIG,
55 CONSTRAINTS_FIELDS, 55 CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
56 PREVIEWS_SIZE, 56 PREVIEWS_SIZE,
57 REMOTE_SCHEME, 57 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 58 STATIC_DOWNLOAD_PATHS,
@@ -70,7 +70,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
70import { ActorModel } from '../activitypub/actor' 70import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 71import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 72import { ServerModel } from '../server/server'
73import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
74import { TagModel } from './tag' 74import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 75import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 76import { VideoChannelModel } from './video-channel'
@@ -93,6 +93,9 @@ import {
93} from './video-format-utils' 93} from './video-format-utils'
94import * as validator from 'validator' 94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 95import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
96 99
97// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 100// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
98const indexes: Sequelize.DefineIndexesOptions[] = [ 101const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -101,17 +104,45 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
101 { fields: [ 'createdAt' ] }, 104 { fields: [ 'createdAt' ] },
102 { fields: [ 'publishedAt' ] }, 105 { fields: [ 'publishedAt' ] },
103 { fields: [ 'duration' ] }, 106 { fields: [ 'duration' ] },
104 { fields: [ 'category' ] },
105 { fields: [ 'licence' ] },
106 { fields: [ 'nsfw' ] },
107 { fields: [ 'language' ] },
108 { fields: [ 'waitTranscoding' ] },
109 { fields: [ 'state' ] },
110 { fields: [ 'remote' ] },
111 { fields: [ 'views' ] }, 107 { fields: [ 'views' ] },
112 { fields: [ 'likes' ] },
113 { fields: [ 'channelId' ] }, 108 { fields: [ 'channelId' ] },
114 { 109 {
110 fields: [ 'category' ], // We don't care videos with an unknown category
111 where: {
112 category: {
113 [Sequelize.Op.ne]: null
114 }
115 }
116 },
117 {
118 fields: [ 'licence' ], // We don't care videos with an unknown licence
119 where: {
120 licence: {
121 [Sequelize.Op.ne]: null
122 }
123 }
124 },
125 {
126 fields: [ 'language' ], // We don't care videos with an unknown language
127 where: {
128 language: {
129 [Sequelize.Op.ne]: null
130 }
131 }
132 },
133 {
134 fields: [ 'nsfw' ], // Most of the videos are not NSFW
135 where: {
136 nsfw: true
137 }
138 },
139 {
140 fields: [ 'remote' ], // Only index local videos
141 where: {
142 remote: false
143 }
144 },
145 {
115 fields: [ 'uuid' ], 146 fields: [ 'uuid' ],
116 unique: true 147 unique: true
117 }, 148 },
@@ -129,7 +160,9 @@ export enum ScopeNames {
129 WITH_FILES = 'WITH_FILES', 160 WITH_FILES = 'WITH_FILES',
130 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 161 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
131 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 162 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
132 WITH_USER_HISTORY = 'WITH_USER_HISTORY' 163 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
164 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
165 WITH_USER_ID = 'WITH_USER_ID'
133} 166}
134 167
135type ForAPIOptions = { 168type ForAPIOptions = {
@@ -138,7 +171,8 @@ type ForAPIOptions = {
138} 171}
139 172
140type AvailableForListIDsOptions = { 173type AvailableForListIDsOptions = {
141 actorId: number 174 serverAccountId: number
175 followerActorId: number
142 includeLocalVideos: boolean 176 includeLocalVideos: boolean
143 filter?: VideoFilter 177 filter?: VideoFilter
144 categoryOneOf?: number[] 178 categoryOneOf?: number[]
@@ -151,6 +185,8 @@ type AvailableForListIDsOptions = {
151 accountId?: number 185 accountId?: number
152 videoChannelId?: number 186 videoChannelId?: number
153 trendingDays?: number 187 trendingDays?: number
188 user?: UserModel,
189 historyOfUser?: UserModel
154} 190}
155 191
156@Scopes({ 192@Scopes({
@@ -236,6 +272,22 @@ type AvailableForListIDsOptions = {
236 } 272 }
237 ] 273 ]
238 }, 274 },
275 channelId: {
276 [ Sequelize.Op.notIn ]: Sequelize.literal(
277 '(' +
278 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
279 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
280 ')' +
281 ')'
282 )
283 }
284 },
285 include: []
286 }
287
288 // Only list public/published videos
289 if (!options.filter || options.filter !== 'all-local') {
290 const privacyWhere = {
239 // Always list public videos 291 // Always list public videos
240 privacy: VideoPrivacy.PUBLIC, 292 privacy: VideoPrivacy.PUBLIC,
241 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding 293 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
@@ -250,8 +302,9 @@ type AvailableForListIDsOptions = {
250 } 302 }
251 } 303 }
252 ] 304 ]
253 }, 305 }
254 include: [] 306
307 Object.assign(query.where, privacyWhere)
255 } 308 }
256 309
257 if (options.filter || options.accountId || options.videoChannelId) { 310 if (options.filter || options.accountId || options.videoChannelId) {
@@ -295,7 +348,7 @@ type AvailableForListIDsOptions = {
295 query.include.push(videoChannelInclude) 348 query.include.push(videoChannelInclude)
296 } 349 }
297 350
298 if (options.actorId) { 351 if (options.followerActorId) {
299 let localVideosReq = '' 352 let localVideosReq = ''
300 if (options.includeLocalVideos === true) { 353 if (options.includeLocalVideos === true) {
301 localVideosReq = ' UNION ALL ' + 354 localVideosReq = ' UNION ALL ' +
@@ -307,7 +360,7 @@ type AvailableForListIDsOptions = {
307 } 360 }
308 361
309 // Force actorId to be a number to avoid SQL injections 362 // Force actorId to be a number to avoid SQL injections
310 const actorIdNumber = parseInt(options.actorId.toString(), 10) 363 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
311 query.where[ 'id' ][ Sequelize.Op.and ].push({ 364 query.where[ 'id' ][ Sequelize.Op.and ].push({
312 [ Sequelize.Op.in ]: Sequelize.literal( 365 [ Sequelize.Op.in ]: Sequelize.literal(
313 '(' + 366 '(' +
@@ -396,8 +449,39 @@ type AvailableForListIDsOptions = {
396 query.subQuery = false 449 query.subQuery = false
397 } 450 }
398 451
452 if (options.historyOfUser) {
453 query.include.push({
454 model: UserVideoHistoryModel,
455 required: true,
456 where: {
457 userId: options.historyOfUser.id
458 }
459 })
460
461 // Even if the relation is n:m, we know that a user only have 0..1 video history
462 // So we won't have multiple rows for the same video
463 // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel
464 query.subQuery = false
465 }
466
399 return query 467 return query
400 }, 468 },
469 [ ScopeNames.WITH_USER_ID ]: {
470 include: [
471 {
472 attributes: [ 'accountId' ],
473 model: () => VideoChannelModel.unscoped(),
474 required: true,
475 include: [
476 {
477 attributes: [ 'userId' ],
478 model: () => AccountModel.unscoped(),
479 required: true
480 }
481 ]
482 }
483 ]
484 },
401 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 485 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
402 include: [ 486 include: [
403 { 487 {
@@ -462,22 +546,55 @@ type AvailableForListIDsOptions = {
462 } 546 }
463 ] 547 ]
464 }, 548 },
465 [ ScopeNames.WITH_FILES ]: { 549 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
466 include: [ 550 let subInclude: any[] = []
467 { 551
468 model: () => VideoFileModel.unscoped(), 552 if (withRedundancies === true) {
469 // FIXME: typings 553 subInclude = [
470 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 554 {
471 required: false, 555 attributes: [ 'fileUrl' ],
472 include: [ 556 model: VideoRedundancyModel.unscoped(),
473 { 557 required: false
474 attributes: [ 'fileUrl' ], 558 }
475 model: () => VideoRedundancyModel.unscoped(), 559 ]
476 required: false 560 }
477 } 561
478 ] 562 return {
479 } 563 include: [
480 ] 564 {
565 model: VideoFileModel.unscoped(),
566 // FIXME: typings
567 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
568 required: false,
569 include: subInclude
570 }
571 ]
572 }
573 },
574 [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
575 let subInclude: any[] = []
576
577 if (withRedundancies === true) {
578 subInclude = [
579 {
580 attributes: [ 'fileUrl' ],
581 model: VideoRedundancyModel.unscoped(),
582 required: false
583 }
584 ]
585 }
586
587 return {
588 include: [
589 {
590 model: VideoStreamingPlaylistModel.unscoped(),
591 // FIXME: typings
592 [ 'separate' as any ]: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
593 required: false,
594 include: subInclude
595 }
596 ]
597 }
481 }, 598 },
482 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 599 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
483 include: [ 600 include: [
@@ -661,6 +778,16 @@ export class VideoModel extends Model<VideoModel> {
661 }) 778 })
662 VideoFiles: VideoFileModel[] 779 VideoFiles: VideoFileModel[]
663 780
781 @HasMany(() => VideoStreamingPlaylistModel, {
782 foreignKey: {
783 name: 'videoId',
784 allowNull: false
785 },
786 hooks: true,
787 onDelete: 'cascade'
788 })
789 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
790
664 @HasMany(() => VideoShareModel, { 791 @HasMany(() => VideoShareModel, {
665 foreignKey: { 792 foreignKey: {
666 name: 'videoId', 793 name: 'videoId',
@@ -725,6 +852,15 @@ export class VideoModel extends Model<VideoModel> {
725 }) 852 })
726 VideoBlacklist: VideoBlacklistModel 853 VideoBlacklist: VideoBlacklistModel
727 854
855 @HasOne(() => VideoImportModel, {
856 foreignKey: {
857 name: 'videoId',
858 allowNull: true
859 },
860 onDelete: 'set null'
861 })
862 VideoImport: VideoImportModel
863
728 @HasMany(() => VideoCaptionModel, { 864 @HasMany(() => VideoCaptionModel, {
729 foreignKey: { 865 foreignKey: {
730 name: 'videoId', 866 name: 'videoId',
@@ -777,6 +913,9 @@ export class VideoModel extends Model<VideoModel> {
777 tasks.push(instance.removeFile(file)) 913 tasks.push(instance.removeFile(file))
778 tasks.push(instance.removeTorrent(file)) 914 tasks.push(instance.removeTorrent(file))
779 }) 915 })
916
917 // Remove playlists file
918 tasks.push(instance.removeStreamingPlaylist())
780 } 919 }
781 920
782 // Do not wait video deletion because we could be in a transaction 921 // Do not wait video deletion because we could be in a transaction
@@ -788,8 +927,14 @@ export class VideoModel extends Model<VideoModel> {
788 return undefined 927 return undefined
789 } 928 }
790 929
791 static list () { 930 static listLocal () {
792 return VideoModel.scope(ScopeNames.WITH_FILES).findAll() 931 const query = {
932 where: {
933 remote: false
934 }
935 }
936
937 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
793 } 938 }
794 939
795 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 940 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -959,10 +1104,15 @@ export class VideoModel extends Model<VideoModel> {
959 filter?: VideoFilter, 1104 filter?: VideoFilter,
960 accountId?: number, 1105 accountId?: number,
961 videoChannelId?: number, 1106 videoChannelId?: number,
962 actorId?: number 1107 followerActorId?: number
963 trendingDays?: number, 1108 trendingDays?: number,
964 userId?: number 1109 user?: UserModel,
1110 historyOfUser?: UserModel
965 }, countVideos = true) { 1111 }, countVideos = true) {
1112 if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1113 throw new Error('Try to filter all-local but no user has not the see all videos right')
1114 }
1115
966 const query: IFindOptions<VideoModel> = { 1116 const query: IFindOptions<VideoModel> = {
967 offset: options.start, 1117 offset: options.start,
968 limit: options.count, 1118 limit: options.count,
@@ -976,11 +1126,14 @@ export class VideoModel extends Model<VideoModel> {
976 query.group = 'VideoModel.id' 1126 query.group = 'VideoModel.id'
977 } 1127 }
978 1128
979 // actorId === null has a meaning, so just check undefined 1129 const serverActor = await getServerActor()
980 const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id 1130
1131 // followerActorId === null has a meaning, so just check undefined
1132 const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
981 1133
982 const queryOptions = { 1134 const queryOptions = {
983 actorId, 1135 followerActorId,
1136 serverAccountId: serverActor.Account.id,
984 nsfw: options.nsfw, 1137 nsfw: options.nsfw,
985 categoryOneOf: options.categoryOneOf, 1138 categoryOneOf: options.categoryOneOf,
986 licenceOneOf: options.licenceOneOf, 1139 licenceOneOf: options.licenceOneOf,
@@ -992,7 +1145,8 @@ export class VideoModel extends Model<VideoModel> {
992 accountId: options.accountId, 1145 accountId: options.accountId,
993 videoChannelId: options.videoChannelId, 1146 videoChannelId: options.videoChannelId,
994 includeLocalVideos: options.includeLocalVideos, 1147 includeLocalVideos: options.includeLocalVideos,
995 userId: options.userId, 1148 user: options.user,
1149 historyOfUser: options.historyOfUser,
996 trendingDays 1150 trendingDays
997 } 1151 }
998 1152
@@ -1015,7 +1169,8 @@ export class VideoModel extends Model<VideoModel> {
1015 tagsAllOf?: string[] 1169 tagsAllOf?: string[]
1016 durationMin?: number // seconds 1170 durationMin?: number // seconds
1017 durationMax?: number // seconds 1171 durationMax?: number // seconds
1018 userId?: number 1172 user?: UserModel,
1173 filter?: VideoFilter
1019 }) { 1174 }) {
1020 const whereAnd = [] 1175 const whereAnd = []
1021 1176
@@ -1084,7 +1239,8 @@ export class VideoModel extends Model<VideoModel> {
1084 1239
1085 const serverActor = await getServerActor() 1240 const serverActor = await getServerActor()
1086 const queryOptions = { 1241 const queryOptions = {
1087 actorId: serverActor.id, 1242 followerActorId: serverActor.id,
1243 serverAccountId: serverActor.Account.id,
1088 includeLocalVideos: options.includeLocalVideos, 1244 includeLocalVideos: options.includeLocalVideos,
1089 nsfw: options.nsfw, 1245 nsfw: options.nsfw,
1090 categoryOneOf: options.categoryOneOf, 1246 categoryOneOf: options.categoryOneOf,
@@ -1092,7 +1248,8 @@ export class VideoModel extends Model<VideoModel> {
1092 languageOneOf: options.languageOneOf, 1248 languageOneOf: options.languageOneOf,
1093 tagsOneOf: options.tagsOneOf, 1249 tagsOneOf: options.tagsOneOf,
1094 tagsAllOf: options.tagsAllOf, 1250 tagsAllOf: options.tagsAllOf,
1095 userId: options.userId 1251 user: options.user,
1252 filter: options.filter
1096 } 1253 }
1097 1254
1098 return VideoModel.getAvailableForApi(query, queryOptions) 1255 return VideoModel.getAvailableForApi(query, queryOptions)
@@ -1108,6 +1265,16 @@ export class VideoModel extends Model<VideoModel> {
1108 return VideoModel.findOne(options) 1265 return VideoModel.findOne(options)
1109 } 1266 }
1110 1267
1268 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
1269 const where = VideoModel.buildWhereIdOrUUID(id)
1270 const options = {
1271 where,
1272 transaction: t
1273 }
1274
1275 return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
1276 }
1277
1111 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1278 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1112 const where = VideoModel.buildWhereIdOrUUID(id) 1279 const where = VideoModel.buildWhereIdOrUUID(id)
1113 1280
@@ -1120,8 +1287,8 @@ export class VideoModel extends Model<VideoModel> {
1120 return VideoModel.findOne(options) 1287 return VideoModel.findOne(options)
1121 } 1288 }
1122 1289
1123 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1290 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1124 return VideoModel.scope(ScopeNames.WITH_FILES) 1291 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
1125 .findById(id, { transaction: t, logging }) 1292 .findById(id, { transaction: t, logging })
1126 } 1293 }
1127 1294
@@ -1132,9 +1299,7 @@ export class VideoModel extends Model<VideoModel> {
1132 } 1299 }
1133 } 1300 }
1134 1301
1135 return VideoModel 1302 return VideoModel.findOne(options)
1136 .scope([ ScopeNames.WITH_FILES ])
1137 .findOne(options)
1138 } 1303 }
1139 1304
1140 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1305 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1156,7 +1321,11 @@ export class VideoModel extends Model<VideoModel> {
1156 transaction 1321 transaction
1157 } 1322 }
1158 1323
1159 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1324 return VideoModel.scope([
1325 ScopeNames.WITH_ACCOUNT_DETAILS,
1326 ScopeNames.WITH_FILES,
1327 ScopeNames.WITH_STREAMING_PLAYLISTS
1328 ]).findOne(query)
1160 } 1329 }
1161 1330
1162 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1331 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
@@ -1171,9 +1340,37 @@ export class VideoModel extends Model<VideoModel> {
1171 const scopes = [ 1340 const scopes = [
1172 ScopeNames.WITH_TAGS, 1341 ScopeNames.WITH_TAGS,
1173 ScopeNames.WITH_BLACKLISTED, 1342 ScopeNames.WITH_BLACKLISTED,
1343 ScopeNames.WITH_ACCOUNT_DETAILS,
1344 ScopeNames.WITH_SCHEDULED_UPDATE,
1174 ScopeNames.WITH_FILES, 1345 ScopeNames.WITH_FILES,
1346 ScopeNames.WITH_STREAMING_PLAYLISTS
1347 ]
1348
1349 if (userId) {
1350 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
1351 }
1352
1353 return VideoModel
1354 .scope(scopes)
1355 .findOne(options)
1356 }
1357
1358 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1359 const where = VideoModel.buildWhereIdOrUUID(id)
1360
1361 const options = {
1362 order: [ [ 'Tags', 'name', 'ASC' ] ],
1363 where,
1364 transaction: t
1365 }
1366
1367 const scopes = [
1368 ScopeNames.WITH_TAGS,
1369 ScopeNames.WITH_BLACKLISTED,
1175 ScopeNames.WITH_ACCOUNT_DETAILS, 1370 ScopeNames.WITH_ACCOUNT_DETAILS,
1176 ScopeNames.WITH_SCHEDULED_UPDATE 1371 ScopeNames.WITH_SCHEDULED_UPDATE,
1372 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1373 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
1177 ] 1374 ]
1178 1375
1179 if (userId) { 1376 if (userId) {
@@ -1217,12 +1414,31 @@ export class VideoModel extends Model<VideoModel> {
1217 }) 1414 })
1218 } 1415 }
1219 1416
1417 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1418 // Instances only share videos
1419 const query = 'SELECT 1 FROM "videoShare" ' +
1420 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1421 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1422 'LIMIT 1'
1423
1424 const options = {
1425 type: Sequelize.QueryTypes.SELECT,
1426 bind: { followerActorId, videoId },
1427 raw: true
1428 }
1429
1430 return VideoModel.sequelize.query(query, options)
1431 .then(results => results.length === 1)
1432 }
1433
1220 // threshold corresponds to how many video the field should have to be returned 1434 // threshold corresponds to how many video the field should have to be returned
1221 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { 1435 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1222 const actorId = (await getServerActor()).id 1436 const serverActor = await getServerActor()
1437 const followerActorId = serverActor.id
1223 1438
1224 const scopeOptions = { 1439 const scopeOptions: AvailableForListIDsOptions = {
1225 actorId, 1440 serverAccountId: serverActor.Account.id,
1441 followerActorId,
1226 includeLocalVideos: true 1442 includeLocalVideos: true
1227 } 1443 }
1228 1444
@@ -1256,7 +1472,7 @@ export class VideoModel extends Model<VideoModel> {
1256 } 1472 }
1257 1473
1258 private static buildActorWhereWithFilter (filter?: VideoFilter) { 1474 private static buildActorWhereWithFilter (filter?: VideoFilter) {
1259 if (filter && filter === 'local') { 1475 if (filter && (filter === 'local' || filter === 'all-local')) {
1260 return { 1476 return {
1261 serverId: null 1477 serverId: null
1262 } 1478 }
@@ -1267,7 +1483,7 @@ export class VideoModel extends Model<VideoModel> {
1267 1483
1268 private static async getAvailableForApi ( 1484 private static async getAvailableForApi (
1269 query: IFindOptions<VideoModel>, 1485 query: IFindOptions<VideoModel>,
1270 options: AvailableForListIDsOptions & { userId?: number}, 1486 options: AvailableForListIDsOptions,
1271 countVideos = true 1487 countVideos = true
1272 ) { 1488 ) {
1273 const idsScope = { 1489 const idsScope = {
@@ -1286,7 +1502,7 @@ export class VideoModel extends Model<VideoModel> {
1286 } 1502 }
1287 1503
1288 const [ count, rowsId ] = await Promise.all([ 1504 const [ count, rowsId ] = await Promise.all([
1289 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined), 1505 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
1290 VideoModel.scope(idsScope).findAll(query) 1506 VideoModel.scope(idsScope).findAll(query)
1291 ]) 1507 ])
1292 const ids = rowsId.map(r => r.id) 1508 const ids = rowsId.map(r => r.id)
@@ -1300,8 +1516,8 @@ export class VideoModel extends Model<VideoModel> {
1300 } 1516 }
1301 ] 1517 ]
1302 1518
1303 if (options.userId) { 1519 if (options.user) {
1304 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] }) 1520 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1305 } 1521 }
1306 1522
1307 const secondQuery = { 1523 const secondQuery = {
@@ -1426,6 +1642,10 @@ export class VideoModel extends Model<VideoModel> {
1426 videoFile.infoHash = parsedTorrent.infoHash 1642 videoFile.infoHash = parsedTorrent.infoHash
1427 } 1643 }
1428 1644
1645 getWatchStaticPath () {
1646 return '/videos/watch/' + this.uuid
1647 }
1648
1429 getEmbedStaticPath () { 1649 getEmbedStaticPath () {
1430 return '/videos/embed/' + this.uuid 1650 return '/videos/embed/' + this.uuid
1431 } 1651 }
@@ -1483,8 +1703,10 @@ export class VideoModel extends Model<VideoModel> {
1483 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err })) 1703 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1484 } 1704 }
1485 1705
1486 removeFile (videoFile: VideoFileModel) { 1706 removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1487 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) 1707 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1708
1709 const filePath = join(baseDir, this.getVideoFilename(videoFile))
1488 return remove(filePath) 1710 return remove(filePath)
1489 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err })) 1711 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1490 } 1712 }
@@ -1495,6 +1717,14 @@ export class VideoModel extends Model<VideoModel> {
1495 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1717 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1496 } 1718 }
1497 1719
1720 removeStreamingPlaylist (isRedundancy = false) {
1721 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY
1722
1723 const filePath = join(baseDir, this.uuid)
1724 return remove(filePath)
1725 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1726 }
1727
1498 isOutdated () { 1728 isOutdated () {
1499 if (this.isOwned()) return false 1729 if (this.isOwned()) return false
1500 1730
@@ -1506,6 +1736,12 @@ export class VideoModel extends Model<VideoModel> {
1506 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL 1736 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
1507 } 1737 }
1508 1738
1739 setAsRefreshed () {
1740 this.changed('updatedAt', true)
1741
1742 return this.save()
1743 }
1744
1509 getBaseUrls () { 1745 getBaseUrls () {
1510 let baseUrlHttp 1746 let baseUrlHttp
1511 let baseUrlWs 1747 let baseUrlWs
@@ -1523,7 +1759,7 @@ export class VideoModel extends Model<VideoModel> {
1523 1759
1524 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1760 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1525 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1761 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1526 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1762 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1527 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1763 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1528 1764
1529 const redundancies = videoFile.RedundancyVideos 1765 const redundancies = videoFile.RedundancyVideos
@@ -1540,6 +1776,10 @@ export class VideoModel extends Model<VideoModel> {
1540 return magnetUtil.encode(magnetHash) 1776 return magnetUtil.encode(magnetHash)
1541 } 1777 }
1542 1778
1779 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1780 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1781 }
1782
1543 getThumbnailUrl (baseUrlHttp: string) { 1783 getThumbnailUrl (baseUrlHttp: string) {
1544 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1784 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1545 } 1785 }
@@ -1556,7 +1796,15 @@ export class VideoModel extends Model<VideoModel> {
1556 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) 1796 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1557 } 1797 }
1558 1798
1799 getVideoRedundancyUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1800 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getVideoFilename(videoFile)
1801 }
1802
1559 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1803 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1560 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1804 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1561 } 1805 }
1806
1807 getBandwidthBits (videoFile: VideoFileModel) {
1808 return Math.ceil((videoFile.size * 8) / this.duration)
1809 }
1562} 1810}