aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/account/user-notification.ts171
-rw-r--r--server/models/redundancy/video-redundancy.ts139
-rw-r--r--server/models/video/video-file.ts6
-rw-r--r--server/models/video/video-format-utils.ts61
-rw-r--r--server/models/video/video-streaming-playlist.ts158
-rw-r--r--server/models/video/video.ts179
6 files changed, 607 insertions, 107 deletions
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index 9e4f982a3..6cdbb827b 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -27,11 +27,33 @@ import { VideoBlacklistModel } from '../video/video-blacklist'
27import { VideoImportModel } from '../video/video-import' 27import { VideoImportModel } from '../video/video-import'
28import { ActorModel } from '../activitypub/actor' 28import { ActorModel } from '../activitypub/actor'
29import { ActorFollowModel } from '../activitypub/actor-follow' 29import { ActorFollowModel } from '../activitypub/actor-follow'
30import { AvatarModel } from '../avatar/avatar'
31import { ServerModel } from '../server/server'
30 32
31enum ScopeNames { 33enum ScopeNames {
32 WITH_ALL = 'WITH_ALL' 34 WITH_ALL = 'WITH_ALL'
33} 35}
34 36
37function buildActorWithAvatarInclude () {
38 return {
39 attributes: [ 'preferredUsername' ],
40 model: () => ActorModel.unscoped(),
41 required: true,
42 include: [
43 {
44 attributes: [ 'filename' ],
45 model: () => AvatarModel.unscoped(),
46 required: false
47 },
48 {
49 attributes: [ 'host' ],
50 model: () => ServerModel.unscoped(),
51 required: false
52 }
53 ]
54 }
55}
56
35function buildVideoInclude (required: boolean) { 57function buildVideoInclude (required: boolean) {
36 return { 58 return {
37 attributes: [ 'id', 'uuid', 'name' ], 59 attributes: [ 'id', 'uuid', 'name' ],
@@ -40,19 +62,21 @@ function buildVideoInclude (required: boolean) {
40 } 62 }
41} 63}
42 64
43function buildChannelInclude (required: boolean) { 65function buildChannelInclude (required: boolean, withActor = false) {
44 return { 66 return {
45 required, 67 required,
46 attributes: [ 'id', 'name' ], 68 attributes: [ 'id', 'name' ],
47 model: () => VideoChannelModel.unscoped() 69 model: () => VideoChannelModel.unscoped(),
70 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
48 } 71 }
49} 72}
50 73
51function buildAccountInclude (required: boolean) { 74function buildAccountInclude (required: boolean, withActor = false) {
52 return { 75 return {
53 required, 76 required,
54 attributes: [ 'id', 'name' ], 77 attributes: [ 'id', 'name' ],
55 model: () => AccountModel.unscoped() 78 model: () => AccountModel.unscoped(),
79 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
56 } 80 }
57} 81}
58 82
@@ -60,47 +84,40 @@ function buildAccountInclude (required: boolean) {
60 [ScopeNames.WITH_ALL]: { 84 [ScopeNames.WITH_ALL]: {
61 include: [ 85 include: [
62 Object.assign(buildVideoInclude(false), { 86 Object.assign(buildVideoInclude(false), {
63 include: [ buildChannelInclude(true) ] 87 include: [ buildChannelInclude(true, true) ]
64 }), 88 }),
89
65 { 90 {
66 attributes: [ 'id', 'originCommentId' ], 91 attributes: [ 'id', 'originCommentId' ],
67 model: () => VideoCommentModel.unscoped(), 92 model: () => VideoCommentModel.unscoped(),
68 required: false, 93 required: false,
69 include: [ 94 include: [
70 buildAccountInclude(true), 95 buildAccountInclude(true, true),
71 buildVideoInclude(true) 96 buildVideoInclude(true)
72 ] 97 ]
73 }, 98 },
99
74 { 100 {
75 attributes: [ 'id' ], 101 attributes: [ 'id' ],
76 model: () => VideoAbuseModel.unscoped(), 102 model: () => VideoAbuseModel.unscoped(),
77 required: false, 103 required: false,
78 include: [ buildVideoInclude(true) ] 104 include: [ buildVideoInclude(true) ]
79 }, 105 },
106
80 { 107 {
81 attributes: [ 'id' ], 108 attributes: [ 'id' ],
82 model: () => VideoBlacklistModel.unscoped(), 109 model: () => VideoBlacklistModel.unscoped(),
83 required: false, 110 required: false,
84 include: [ buildVideoInclude(true) ] 111 include: [ buildVideoInclude(true) ]
85 }, 112 },
113
86 { 114 {
87 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ], 115 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
88 model: () => VideoImportModel.unscoped(), 116 model: () => VideoImportModel.unscoped(),
89 required: false, 117 required: false,
90 include: [ buildVideoInclude(false) ] 118 include: [ buildVideoInclude(false) ]
91 }, 119 },
92 { 120
93 attributes: [ 'id', 'name' ],
94 model: () => AccountModel.unscoped(),
95 required: false,
96 include: [
97 {
98 attributes: [ 'id', 'preferredUsername' ],
99 model: () => ActorModel.unscoped(),
100 required: true
101 }
102 ]
103 },
104 { 121 {
105 attributes: [ 'id' ], 122 attributes: [ 'id' ],
106 model: () => ActorFollowModel.unscoped(), 123 model: () => ActorFollowModel.unscoped(),
@@ -111,7 +128,23 @@ function buildAccountInclude (required: boolean) {
111 model: () => ActorModel.unscoped(), 128 model: () => ActorModel.unscoped(),
112 required: true, 129 required: true,
113 as: 'ActorFollower', 130 as: 'ActorFollower',
114 include: [ buildAccountInclude(true) ] 131 include: [
132 {
133 attributes: [ 'id', 'name' ],
134 model: () => AccountModel.unscoped(),
135 required: true
136 },
137 {
138 attributes: [ 'filename' ],
139 model: () => AvatarModel.unscoped(),
140 required: false
141 },
142 {
143 attributes: [ 'host' ],
144 model: () => ServerModel.unscoped(),
145 required: false
146 }
147 ]
115 }, 148 },
116 { 149 {
117 attributes: [ 'preferredUsername' ], 150 attributes: [ 'preferredUsername' ],
@@ -124,7 +157,9 @@ function buildAccountInclude (required: boolean) {
124 ] 157 ]
125 } 158 }
126 ] 159 ]
127 } 160 },
161
162 buildAccountInclude(false, true)
128 ] 163 ]
129 } 164 }
130}) 165})
@@ -132,10 +167,63 @@ function buildAccountInclude (required: boolean) {
132 tableName: 'userNotification', 167 tableName: 'userNotification',
133 indexes: [ 168 indexes: [
134 { 169 {
135 fields: [ 'videoId' ] 170 fields: [ 'userId' ]
171 },
172 {
173 fields: [ 'videoId' ],
174 where: {
175 videoId: {
176 [Op.ne]: null
177 }
178 }
136 }, 179 },
137 { 180 {
138 fields: [ 'commentId' ] 181 fields: [ 'commentId' ],
182 where: {
183 commentId: {
184 [Op.ne]: null
185 }
186 }
187 },
188 {
189 fields: [ 'videoAbuseId' ],
190 where: {
191 videoAbuseId: {
192 [Op.ne]: null
193 }
194 }
195 },
196 {
197 fields: [ 'videoBlacklistId' ],
198 where: {
199 videoBlacklistId: {
200 [Op.ne]: null
201 }
202 }
203 },
204 {
205 fields: [ 'videoImportId' ],
206 where: {
207 videoImportId: {
208 [Op.ne]: null
209 }
210 }
211 },
212 {
213 fields: [ 'accountId' ],
214 where: {
215 accountId: {
216 [Op.ne]: null
217 }
218 }
219 },
220 {
221 fields: [ 'actorFollowId' ],
222 where: {
223 actorFollowId: {
224 [Op.ne]: null
225 }
226 }
139 } 227 }
140 ] 228 ]
141}) 229})
@@ -297,12 +385,9 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
297 } 385 }
298 386
299 toFormattedJSON (): UserNotification { 387 toFormattedJSON (): UserNotification {
300 const video = this.Video ? Object.assign(this.formatVideo(this.Video), { 388 const video = this.Video
301 channel: { 389 ? Object.assign(this.formatVideo(this.Video),{ channel: this.formatActor(this.Video.VideoChannel) })
302 id: this.Video.VideoChannel.id, 390 : undefined
303 displayName: this.Video.VideoChannel.getDisplayName()
304 }
305 }) : undefined
306 391
307 const videoImport = this.VideoImport ? { 392 const videoImport = this.VideoImport ? {
308 id: this.VideoImport.id, 393 id: this.VideoImport.id,
@@ -315,10 +400,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
315 const comment = this.Comment ? { 400 const comment = this.Comment ? {
316 id: this.Comment.id, 401 id: this.Comment.id,
317 threadId: this.Comment.getThreadId(), 402 threadId: this.Comment.getThreadId(),
318 account: { 403 account: this.formatActor(this.Comment.Account),
319 id: this.Comment.Account.id,
320 displayName: this.Comment.Account.getDisplayName()
321 },
322 video: this.formatVideo(this.Comment.Video) 404 video: this.formatVideo(this.Comment.Video)
323 } : undefined 405 } : undefined
324 406
@@ -332,17 +414,16 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
332 video: this.formatVideo(this.VideoBlacklist.Video) 414 video: this.formatVideo(this.VideoBlacklist.Video)
333 } : undefined 415 } : undefined
334 416
335 const account = this.Account ? { 417 const account = this.Account ? this.formatActor(this.Account) : undefined
336 id: this.Account.id,
337 displayName: this.Account.getDisplayName(),
338 name: this.Account.Actor.preferredUsername
339 } : undefined
340 418
341 const actorFollow = this.ActorFollow ? { 419 const actorFollow = this.ActorFollow ? {
342 id: this.ActorFollow.id, 420 id: this.ActorFollow.id,
343 follower: { 421 follower: {
422 id: this.ActorFollow.ActorFollower.Account.id,
344 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), 423 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
345 name: this.ActorFollow.ActorFollower.preferredUsername 424 name: this.ActorFollow.ActorFollower.preferredUsername,
425 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getWebserverPath() } : undefined,
426 host: this.ActorFollow.ActorFollower.getHost()
346 }, 427 },
347 following: { 428 following: {
348 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account', 429 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
@@ -374,4 +455,18 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
374 name: video.name 455 name: video.name
375 } 456 }
376 } 457 }
458
459 private formatActor (accountOrChannel: AccountModel | VideoChannelModel) {
460 const avatar = accountOrChannel.Actor.Avatar
461 ? { path: accountOrChannel.Actor.Avatar.getWebserverPath() }
462 : undefined
463
464 return {
465 id: accountOrChannel.id,
466 displayName: accountOrChannel.getDisplayName(),
467 name: accountOrChannel.Actor.preferredUsername,
468 host: accountOrChannel.Actor.getHost(),
469 avatar
470 }
471 }
377} 472}
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 8f2ef2d9a..b722bed14 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -28,6 +28,7 @@ import { sample } from 'lodash'
28import { isTestInstance } from '../../helpers/core-utils' 28import { isTestInstance } from '../../helpers/core-utils'
29import * as Bluebird from 'bluebird' 29import * as Bluebird from 'bluebird'
30import * as Sequelize from 'sequelize' 30import * as Sequelize from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
31 32
32export enum ScopeNames { 33export enum ScopeNames {
33 WITH_VIDEO = 'WITH_VIDEO' 34 WITH_VIDEO = 'WITH_VIDEO'
@@ -38,7 +39,17 @@ export enum ScopeNames {
38 include: [ 39 include: [
39 { 40 {
40 model: () => VideoFileModel, 41 model: () => VideoFileModel,
41 required: true, 42 required: false,
43 include: [
44 {
45 model: () => VideoModel,
46 required: true
47 }
48 ]
49 },
50 {
51 model: () => VideoStreamingPlaylistModel,
52 required: false,
42 include: [ 53 include: [
43 { 54 {
44 model: () => VideoModel, 55 model: () => VideoModel,
@@ -97,12 +108,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
97 108
98 @BelongsTo(() => VideoFileModel, { 109 @BelongsTo(() => VideoFileModel, {
99 foreignKey: { 110 foreignKey: {
100 allowNull: false 111 allowNull: true
101 }, 112 },
102 onDelete: 'cascade' 113 onDelete: 'cascade'
103 }) 114 })
104 VideoFile: VideoFileModel 115 VideoFile: VideoFileModel
105 116
117 @ForeignKey(() => VideoStreamingPlaylistModel)
118 @Column
119 videoStreamingPlaylistId: number
120
121 @BelongsTo(() => VideoStreamingPlaylistModel, {
122 foreignKey: {
123 allowNull: true
124 },
125 onDelete: 'cascade'
126 })
127 VideoStreamingPlaylist: VideoStreamingPlaylistModel
128
106 @ForeignKey(() => ActorModel) 129 @ForeignKey(() => ActorModel)
107 @Column 130 @Column
108 actorId: number 131 actorId: number
@@ -119,13 +142,25 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
119 static async removeFile (instance: VideoRedundancyModel) { 142 static async removeFile (instance: VideoRedundancyModel) {
120 if (!instance.isOwned()) return 143 if (!instance.isOwned()) return
121 144
122 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) 145 if (instance.videoFileId) {
146 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
123 147
124 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 148 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
125 logger.info('Removing duplicated video file %s.', logIdentifier) 149 logger.info('Removing duplicated video file %s.', logIdentifier)
126 150
127 videoFile.Video.removeFile(videoFile, true) 151 videoFile.Video.removeFile(videoFile, true)
128 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 152 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
153 }
154
155 if (instance.videoStreamingPlaylistId) {
156 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
157
158 const videoUUID = videoStreamingPlaylist.Video.uuid
159 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
160
161 videoStreamingPlaylist.Video.removeStreamingPlaylist(true)
162 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
163 }
129 164
130 return undefined 165 return undefined
131 } 166 }
@@ -143,6 +178,19 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
143 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) 178 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
144 } 179 }
145 180
181 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) {
182 const actor = await getServerActor()
183
184 const query = {
185 where: {
186 actorId: actor.id,
187 videoStreamingPlaylistId
188 }
189 }
190
191 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
192 }
193
146 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 194 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
147 const query = { 195 const query = {
148 where: { 196 where: {
@@ -191,7 +239,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
191 const ids = rows.map(r => r.id) 239 const ids = rows.map(r => r.id)
192 const id = sample(ids) 240 const id = sample(ids)
193 241
194 return VideoModel.loadWithFile(id, undefined, !isTestInstance()) 242 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
195 } 243 }
196 244
197 static async findMostViewToDuplicate (randomizedFactor: number) { 245 static async findMostViewToDuplicate (randomizedFactor: number) {
@@ -333,40 +381,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
333 381
334 static async listLocalOfServer (serverId: number) { 382 static async listLocalOfServer (serverId: number) {
335 const actor = await getServerActor() 383 const actor = await getServerActor()
336 384 const buildVideoInclude = () => ({
337 const query = { 385 model: VideoModel,
338 where: { 386 required: true,
339 actorId: actor.id
340 },
341 include: [ 387 include: [
342 { 388 {
343 model: VideoFileModel, 389 attributes: [],
390 model: VideoChannelModel.unscoped(),
344 required: true, 391 required: true,
345 include: [ 392 include: [
346 { 393 {
347 model: VideoModel, 394 attributes: [],
395 model: ActorModel.unscoped(),
348 required: true, 396 required: true,
349 include: [ 397 where: {
350 { 398 serverId
351 attributes: [], 399 }
352 model: VideoChannelModel.unscoped(),
353 required: true,
354 include: [
355 {
356 attributes: [],
357 model: ActorModel.unscoped(),
358 required: true,
359 where: {
360 serverId
361 }
362 }
363 ]
364 }
365 ]
366 } 400 }
367 ] 401 ]
368 } 402 }
369 ] 403 ]
404 })
405
406 const query = {
407 where: {
408 actorId: actor.id
409 },
410 include: [
411 {
412 model: VideoFileModel,
413 required: false,
414 include: [ buildVideoInclude() ]
415 },
416 {
417 model: VideoStreamingPlaylistModel,
418 required: false,
419 include: [ buildVideoInclude() ]
420 }
421 ]
370 } 422 }
371 423
372 return VideoRedundancyModel.findAll(query) 424 return VideoRedundancyModel.findAll(query)
@@ -403,11 +455,32 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
403 })) 455 }))
404 } 456 }
405 457
458 getVideo () {
459 if (this.VideoFile) return this.VideoFile.Video
460
461 return this.VideoStreamingPlaylist.Video
462 }
463
406 isOwned () { 464 isOwned () {
407 return !!this.strategy 465 return !!this.strategy
408 } 466 }
409 467
410 toActivityPubObject (): CacheFileObject { 468 toActivityPubObject (): CacheFileObject {
469 if (this.VideoStreamingPlaylist) {
470 return {
471 id: this.url,
472 type: 'CacheFile' as 'CacheFile',
473 object: this.VideoStreamingPlaylist.Video.url,
474 expires: this.expiresOn.toISOString(),
475 url: {
476 type: 'Link',
477 mimeType: 'application/x-mpegURL',
478 mediaType: 'application/x-mpegURL',
479 href: this.fileUrl
480 }
481 }
482 }
483
411 return { 484 return {
412 id: this.url, 485 id: this.url,
413 type: 'CacheFile' as 'CacheFile', 486 type: 'CacheFile' as 'CacheFile',
@@ -431,7 +504,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
431 504
432 const notIn = Sequelize.literal( 505 const notIn = Sequelize.literal(
433 '(' + 506 '(' +
434 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` + 507 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` +
435 ')' 508 ')'
436 ) 509 )
437 510
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 1f1b76c1e..7d1e371b9 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -62,7 +62,7 @@ export class VideoFileModel extends Model<VideoFileModel> {
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,
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index de0747f22..e49dbee30 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -1,7 +1,12 @@
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 {
5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
5import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers' 10import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
7import { 12import {
@@ -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(),
@@ -133,7 +145,11 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
133 id: video.state, 145 id: video.state,
134 label: VideoModel.getStateLabel(video.state) 146 label: VideoModel.getStateLabel(video.state)
135 }, 147 },
136 files: [] 148
149 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
150
151 files: [],
152 streamingPlaylists
137 } 153 }
138 154
139 // Format and sort video files 155 // Format and sort video files
@@ -142,6 +158,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
142 return Object.assign(formattedJson, detailsJson) 158 return Object.assign(formattedJson, detailsJson)
143} 159}
144 160
161function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
162 if (isArray(playlists) === false) return []
163
164 return playlists
165 .map(playlist => {
166 const redundancies = isArray(playlist.RedundancyVideos)
167 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
168 : []
169
170 return {
171 id: playlist.id,
172 type: playlist.type,
173 playlistUrl: playlist.playlistUrl,
174 segmentsSha256Url: playlist.segmentsSha256Url,
175 redundancies
176 } as VideoStreamingPlaylist
177 })
178}
179
145function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 180function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
146 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 181 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
147 182
@@ -232,6 +267,28 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
232 }) 267 })
233 } 268 }
234 269
270 for (const playlist of (video.VideoStreamingPlaylists || [])) {
271 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
272
273 tag = playlist.p2pMediaLoaderInfohashes
274 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
275 tag.push({
276 type: 'Link',
277 name: 'sha256',
278 mimeType: 'application/json' as 'application/json',
279 mediaType: 'application/json' as 'application/json',
280 href: playlist.segmentsSha256Url
281 })
282
283 url.push({
284 type: 'Link',
285 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
286 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
287 href: playlist.playlistUrl,
288 tag
289 })
290 }
291
235 // Add video url too 292 // Add video url too
236 url.push({ 293 url.push({
237 type: 'Link', 294 type: 'Link',
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 80a6c7832..702260772 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -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,
@@ -95,6 +95,7 @@ import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 95import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user' 96import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import' 97import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
98 99
99// 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
100const indexes: Sequelize.DefineIndexesOptions[] = [ 101const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -159,7 +160,9 @@ export enum ScopeNames {
159 WITH_FILES = 'WITH_FILES', 160 WITH_FILES = 'WITH_FILES',
160 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 161 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
161 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 162 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
162 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'
163} 166}
164 167
165type ForAPIOptions = { 168type ForAPIOptions = {
@@ -463,6 +466,22 @@ type AvailableForListIDsOptions = {
463 466
464 return query 467 return query
465 }, 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 },
466 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 485 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
467 include: [ 486 include: [
468 { 487 {
@@ -527,22 +546,55 @@ type AvailableForListIDsOptions = {
527 } 546 }
528 ] 547 ]
529 }, 548 },
530 [ ScopeNames.WITH_FILES ]: { 549 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
531 include: [ 550 let subInclude: any[] = []
532 { 551
533 model: () => VideoFileModel.unscoped(), 552 if (withRedundancies === true) {
534 // FIXME: typings 553 subInclude = [
535 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 554 {
536 required: false, 555 attributes: [ 'fileUrl' ],
537 include: [ 556 model: VideoRedundancyModel.unscoped(),
538 { 557 required: false
539 attributes: [ 'fileUrl' ], 558 }
540 model: () => VideoRedundancyModel.unscoped(), 559 ]
541 required: false 560 }
542 } 561
543 ] 562 return {
544 } 563 include: [
545 ] 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 }
546 }, 598 },
547 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 599 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
548 include: [ 600 include: [
@@ -722,6 +774,16 @@ export class VideoModel extends Model<VideoModel> {
722 }) 774 })
723 VideoFiles: VideoFileModel[] 775 VideoFiles: VideoFileModel[]
724 776
777 @HasMany(() => VideoStreamingPlaylistModel, {
778 foreignKey: {
779 name: 'videoId',
780 allowNull: false
781 },
782 hooks: true,
783 onDelete: 'cascade'
784 })
785 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
786
725 @HasMany(() => VideoShareModel, { 787 @HasMany(() => VideoShareModel, {
726 foreignKey: { 788 foreignKey: {
727 name: 'videoId', 789 name: 'videoId',
@@ -847,6 +909,9 @@ export class VideoModel extends Model<VideoModel> {
847 tasks.push(instance.removeFile(file)) 909 tasks.push(instance.removeFile(file))
848 tasks.push(instance.removeTorrent(file)) 910 tasks.push(instance.removeTorrent(file))
849 }) 911 })
912
913 // Remove playlists file
914 tasks.push(instance.removeStreamingPlaylist())
850 } 915 }
851 916
852 // Do not wait video deletion because we could be in a transaction 917 // Do not wait video deletion because we could be in a transaction
@@ -858,10 +923,6 @@ export class VideoModel extends Model<VideoModel> {
858 return undefined 923 return undefined
859 } 924 }
860 925
861 static list () {
862 return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
863 }
864
865 static listLocal () { 926 static listLocal () {
866 const query = { 927 const query = {
867 where: { 928 where: {
@@ -869,7 +930,7 @@ export class VideoModel extends Model<VideoModel> {
869 } 930 }
870 } 931 }
871 932
872 return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query) 933 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
873 } 934 }
874 935
875 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 936 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -1200,6 +1261,16 @@ export class VideoModel extends Model<VideoModel> {
1200 return VideoModel.findOne(options) 1261 return VideoModel.findOne(options)
1201 } 1262 }
1202 1263
1264 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
1265 const where = VideoModel.buildWhereIdOrUUID(id)
1266 const options = {
1267 where,
1268 transaction: t
1269 }
1270
1271 return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
1272 }
1273
1203 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1274 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1204 const where = VideoModel.buildWhereIdOrUUID(id) 1275 const where = VideoModel.buildWhereIdOrUUID(id)
1205 1276
@@ -1212,8 +1283,8 @@ export class VideoModel extends Model<VideoModel> {
1212 return VideoModel.findOne(options) 1283 return VideoModel.findOne(options)
1213 } 1284 }
1214 1285
1215 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1286 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1216 return VideoModel.scope(ScopeNames.WITH_FILES) 1287 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
1217 .findById(id, { transaction: t, logging }) 1288 .findById(id, { transaction: t, logging })
1218 } 1289 }
1219 1290
@@ -1224,9 +1295,7 @@ export class VideoModel extends Model<VideoModel> {
1224 } 1295 }
1225 } 1296 }
1226 1297
1227 return VideoModel 1298 return VideoModel.findOne(options)
1228 .scope([ ScopeNames.WITH_FILES ])
1229 .findOne(options)
1230 } 1299 }
1231 1300
1232 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1301 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1248,7 +1317,11 @@ export class VideoModel extends Model<VideoModel> {
1248 transaction 1317 transaction
1249 } 1318 }
1250 1319
1251 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1320 return VideoModel.scope([
1321 ScopeNames.WITH_ACCOUNT_DETAILS,
1322 ScopeNames.WITH_FILES,
1323 ScopeNames.WITH_STREAMING_PLAYLISTS
1324 ]).findOne(query)
1252 } 1325 }
1253 1326
1254 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1327 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
@@ -1263,9 +1336,37 @@ export class VideoModel extends Model<VideoModel> {
1263 const scopes = [ 1336 const scopes = [
1264 ScopeNames.WITH_TAGS, 1337 ScopeNames.WITH_TAGS,
1265 ScopeNames.WITH_BLACKLISTED, 1338 ScopeNames.WITH_BLACKLISTED,
1339 ScopeNames.WITH_ACCOUNT_DETAILS,
1340 ScopeNames.WITH_SCHEDULED_UPDATE,
1266 ScopeNames.WITH_FILES, 1341 ScopeNames.WITH_FILES,
1342 ScopeNames.WITH_STREAMING_PLAYLISTS
1343 ]
1344
1345 if (userId) {
1346 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
1347 }
1348
1349 return VideoModel
1350 .scope(scopes)
1351 .findOne(options)
1352 }
1353
1354 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1355 const where = VideoModel.buildWhereIdOrUUID(id)
1356
1357 const options = {
1358 order: [ [ 'Tags', 'name', 'ASC' ] ],
1359 where,
1360 transaction: t
1361 }
1362
1363 const scopes = [
1364 ScopeNames.WITH_TAGS,
1365 ScopeNames.WITH_BLACKLISTED,
1267 ScopeNames.WITH_ACCOUNT_DETAILS, 1366 ScopeNames.WITH_ACCOUNT_DETAILS,
1268 ScopeNames.WITH_SCHEDULED_UPDATE 1367 ScopeNames.WITH_SCHEDULED_UPDATE,
1368 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1369 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
1269 ] 1370 ]
1270 1371
1271 if (userId) { 1372 if (userId) {
@@ -1612,6 +1713,14 @@ export class VideoModel extends Model<VideoModel> {
1612 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1713 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1613 } 1714 }
1614 1715
1716 removeStreamingPlaylist (isRedundancy = false) {
1717 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY
1718
1719 const filePath = join(baseDir, this.uuid)
1720 return remove(filePath)
1721 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1722 }
1723
1615 isOutdated () { 1724 isOutdated () {
1616 if (this.isOwned()) return false 1725 if (this.isOwned()) return false
1617 1726
@@ -1646,7 +1755,7 @@ export class VideoModel extends Model<VideoModel> {
1646 1755
1647 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1756 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1648 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1757 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1649 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1758 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1650 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1759 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1651 1760
1652 const redundancies = videoFile.RedundancyVideos 1761 const redundancies = videoFile.RedundancyVideos
@@ -1663,6 +1772,10 @@ export class VideoModel extends Model<VideoModel> {
1663 return magnetUtil.encode(magnetHash) 1772 return magnetUtil.encode(magnetHash)
1664 } 1773 }
1665 1774
1775 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1776 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1777 }
1778
1666 getThumbnailUrl (baseUrlHttp: string) { 1779 getThumbnailUrl (baseUrlHttp: string) {
1667 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1780 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1668 } 1781 }
@@ -1686,4 +1799,8 @@ export class VideoModel extends Model<VideoModel> {
1686 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1799 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1687 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1800 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1688 } 1801 }
1802
1803 getBandwidthBits (videoFile: VideoFileModel) {
1804 return Math.ceil((videoFile.size * 8) / this.duration)
1805 }
1689} 1806}