diff options
Diffstat (limited to 'server/models/video/video.ts')
-rw-r--r-- | server/models/video/video.ts | 1845 |
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' | |||
4 | import * as parseTorrent from 'parse-torrent' | 4 | import * as parseTorrent from 'parse-torrent' |
5 | import { join } from 'path' | 5 | import { join } from 'path' |
6 | import * as Sequelize from 'sequelize' | 6 | import * as Sequelize from 'sequelize' |
7 | import { | ||
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' | ||
27 | import { IIncludeOptions } from 'sequelize-typescript/lib/interfaces/IIncludeOptions' | ||
7 | import { VideoPrivacy, VideoResolution } from '../../../shared' | 28 | import { VideoPrivacy, VideoResolution } from '../../../shared' |
8 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object' | 29 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' |
9 | import { activityPubCollection } from '../../helpers/activitypub' | 30 | import { |
10 | import { createTorrentPromise, renamePromise, statPromise, unlinkPromise, writeFilePromise } from '../../helpers/core-utils' | 31 | activityPubCollection, |
11 | import { isVideoCategoryValid, isVideoLanguageValid, isVideoPrivacyValid } from '../../helpers/custom-validators/videos' | 32 | createTorrentPromise, |
12 | import { 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' | ||
42 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub' | ||
13 | import { | 43 | import { |
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 |
21 | import { logger } from '../../helpers/logger' | 52 | } from '../../helpers/custom-validators/videos' |
22 | import { | 53 | import { |
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' |
35 | import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url' | 66 | import { getAnnounceActivityPubUrl } from '../../lib/activitypub' |
36 | import { sendDeleteVideo } from '../../lib/index' | 67 | import { sendDeleteVideo } from '../../lib/index' |
37 | import { addMethodsToModel, getSort } from '../utils' | 68 | import { AccountModel } from '../account/account' |
38 | import { TagInstance } from './tag-interface' | 69 | import { AccountVideoRateModel } from '../account/account-video-rate' |
39 | import { VideoFileInstance, VideoFileModel } from './video-file-interface' | 70 | import { ServerModel } from '../server/server' |
40 | import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface' | 71 | import { getSort, throwIfNotValid } from '../utils' |
41 | 72 | import { TagModel } from './tag' | |
42 | let Video: Sequelize.Model<VideoInstance, VideoAttributes> | 73 | import { VideoAbuseModel } from './video-abuse' |
43 | let getOriginalFile: VideoMethods.GetOriginalFile | 74 | import { VideoChannelModel } from './video-channel' |
44 | let getVideoFilename: VideoMethods.GetVideoFilename | 75 | import { VideoFileModel } from './video-file' |
45 | let getThumbnailName: VideoMethods.GetThumbnailName | 76 | import { VideoShareModel } from './video-share' |
46 | let getThumbnailPath: VideoMethods.GetThumbnailPath | 77 | import { VideoTagModel } from './video-tag' |
47 | let getPreviewName: VideoMethods.GetPreviewName | 78 | |
48 | let getPreviewPath: VideoMethods.GetPreviewPath | 79 | @Table({ |
49 | let getTorrentFileName: VideoMethods.GetTorrentFileName | 80 | tableName: 'video', |
50 | let isOwned: VideoMethods.IsOwned | 81 | indexes: [ |
51 | let toFormattedJSON: VideoMethods.ToFormattedJSON | ||
52 | let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON | ||
53 | let toActivityPubObject: VideoMethods.ToActivityPubObject | ||
54 | let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile | ||
55 | let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile | ||
56 | let createPreview: VideoMethods.CreatePreview | ||
57 | let createThumbnail: VideoMethods.CreateThumbnail | ||
58 | let getVideoFilePath: VideoMethods.GetVideoFilePath | ||
59 | let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash | ||
60 | let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight | ||
61 | let getEmbedPath: VideoMethods.GetEmbedPath | ||
62 | let getDescriptionPath: VideoMethods.GetDescriptionPath | ||
63 | let getTruncatedDescription: VideoMethods.GetTruncatedDescription | ||
64 | let getCategoryLabel: VideoMethods.GetCategoryLabel | ||
65 | let getLicenceLabel: VideoMethods.GetLicenceLabel | ||
66 | let getLanguageLabel: VideoMethods.GetLanguageLabel | ||
67 | |||
68 | let list: VideoMethods.List | ||
69 | let listForApi: VideoMethods.ListForApi | ||
70 | let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox | ||
71 | let listUserVideosForApi: VideoMethods.ListUserVideosForApi | ||
72 | let load: VideoMethods.Load | ||
73 | let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount | ||
74 | let loadByUUID: VideoMethods.LoadByUUID | ||
75 | let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL | ||
76 | let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags | ||
77 | let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags | ||
78 | let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags | ||
79 | let removeThumbnail: VideoMethods.RemoveThumbnail | ||
80 | let removePreview: VideoMethods.RemovePreview | ||
81 | let removeFile: VideoMethods.RemoveFile | ||
82 | let removeTorrent: VideoMethods.RemoveTorrent | ||
83 | |||
84 | export 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 | 105 | export 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) | |
304 | function 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 | |||
352 | function 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 | |||
378 | getOriginalFile = 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 | ||
385 | getVideoFilename = 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 | ||
389 | getThumbnailName = 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 | ||
395 | getPreviewName = function (this: VideoInstance) { | 280 | return VideoModel.findAll(query) |
396 | const extension = '.jpg' | 281 | } |
397 | return this.uuid + extension | ||
398 | } | ||
399 | 282 | ||
400 | getTorrentFileName = 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 | ||
405 | isOwned = function (this: VideoInstance) { | 292 | return `(${queryVideo}) UNION (${queryVideoShare})` |
406 | return this.remote === false | 293 | } |
407 | } | ||
408 | 294 | ||
409 | createPreview = 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 | ||
420 | createThumbnail = 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 | ||
431 | getVideoFilePath = 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 | ||
435 | createTorrentAndSetInfoHash = 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 | ||
456 | getEmbedPath = 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 | ||
460 | getThumbnailPath = function (this: VideoInstance) { | 463 | if (t !== undefined) query.transaction = t |
461 | return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) | ||
462 | } | ||
463 | 464 | ||
464 | getPreviewPath = function (this: VideoInstance) { | 465 | return VideoModel.findOne(query) |
465 | return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) | 466 | } |
466 | } | ||
467 | 467 | ||
468 | toFormattedJSON = 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 | ||
508 | toFormattedDetailsJSON = 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 | |||
550 | toActivityPubObject = 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 | ||
679 | getTruncatedDescription = 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 | ||
689 | optimizeOriginalVideofile = 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 | ||
726 | transcodeOriginalVideofile = 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 | ||
760 | getOriginalFileHeight = 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 | ||
766 | getDescriptionPath = 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 | ||
770 | getCategoryLabel = 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 | ||
777 | getLicenceLabel = 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 | ||
784 | getLanguageLabel = 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 | ||
791 | removeThumbnail = 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 | ||
796 | removePreview = 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 | ||
801 | removeFile = 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 | ||
806 | removeTorrent = 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 | ||
813 | list = 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 | ||
821 | listAllAndSharedByAccountForOutbox = 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 | ||
903 | listUserVideosForApi = 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 | ||
935 | listForApi = 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 | ||
971 | load = 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 | ||
975 | loadByUUID = 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 | ||
988 | loadByUrlAndPopulateAccount = 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 | ||
1007 | loadByUUIDOrURL = 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 | ||
1023 | loadAndPopulateAccountAndServerAndTags = 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 | ||
1052 | loadByUUIDAndPopulateAccountAndServerAndTags = 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 | ||
1084 | searchAndPopulateAccountAndServerAndTags = 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 | ||
1147 | function 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 | ||
1158 | function 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 | } | |
1173 | function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) { | ||
1174 | return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName() | ||
1175 | } | ||
1176 | 1066 | ||
1177 | function 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 | ||
1181 | function 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 | ||
1185 | function 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 | } |