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