]>
Commit | Line | Data |
---|---|---|
c48e82b5 C |
1 | import { |
2 | AllowNull, | |
3 | BelongsTo, | |
4 | Column, | |
5 | CreatedAt, | |
6 | DataType, | |
7 | Default, | |
8 | ForeignKey, | |
9 | HasMany, | |
10 | Is, | |
11 | Model, | |
12 | Table, | |
8319d6ae RK |
13 | UpdatedAt, |
14 | Scopes, | |
15 | DefaultScope | |
c48e82b5 | 16 | } from 'sequelize-typescript' |
3a6f351b | 17 | import { |
14e2014a | 18 | isVideoFileExtnameValid, |
3a6f351b C |
19 | isVideoFileInfoHashValid, |
20 | isVideoFileResolutionValid, | |
21 | isVideoFileSizeValid, | |
22 | isVideoFPSResolutionValid | |
23 | } from '../../helpers/custom-validators/videos' | |
3acc5084 | 24 | import { parseAggregateResult, throwIfNotValid } from '../utils' |
3fd3ab2d | 25 | import { VideoModel } from './video' |
c48e82b5 | 26 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' |
ae9bbed4 | 27 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' |
d7a25329 | 28 | import { FindOptions, Op, QueryTypes, Transaction } from 'sequelize' |
35f28e94 | 29 | import { MIMETYPES, MEMOIZE_LENGTH, MEMOIZE_TTL } from '../../initializers/constants' |
26d6bf65 C |
30 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' |
31 | import { MStreamingPlaylistVideo, MVideo } from '@server/types/models' | |
35f28e94 | 32 | import * as memoizee from 'memoizee' |
7b81edc8 | 33 | import validator from 'validator' |
93e1258c | 34 | |
8319d6ae RK |
35 | export enum ScopeNames { |
36 | WITH_VIDEO = 'WITH_VIDEO', | |
8319d6ae RK |
37 | WITH_METADATA = 'WITH_METADATA' |
38 | } | |
39 | ||
8319d6ae RK |
40 | @DefaultScope(() => ({ |
41 | attributes: { | |
7b81edc8 | 42 | exclude: [ 'metadata' ] |
8319d6ae RK |
43 | } |
44 | })) | |
45 | @Scopes(() => ({ | |
46 | [ScopeNames.WITH_VIDEO]: { | |
47 | include: [ | |
48 | { | |
49 | model: VideoModel.unscoped(), | |
50 | required: true | |
51 | } | |
52 | ] | |
53 | }, | |
8319d6ae RK |
54 | [ScopeNames.WITH_METADATA]: { |
55 | attributes: { | |
7b81edc8 | 56 | include: [ 'metadata' ] |
8319d6ae RK |
57 | } |
58 | } | |
59 | })) | |
3fd3ab2d C |
60 | @Table({ |
61 | tableName: 'videoFile', | |
62 | indexes: [ | |
93e1258c | 63 | { |
d7a25329 C |
64 | fields: [ 'videoId' ], |
65 | where: { | |
66 | videoId: { | |
67 | [Op.ne]: null | |
68 | } | |
69 | } | |
70 | }, | |
71 | { | |
72 | fields: [ 'videoStreamingPlaylistId' ], | |
73 | where: { | |
74 | videoStreamingPlaylistId: { | |
75 | [Op.ne]: null | |
76 | } | |
77 | } | |
93e1258c | 78 | }, |
d7a25329 | 79 | |
93e1258c | 80 | { |
3fd3ab2d | 81 | fields: [ 'infoHash' ] |
8cd72bd3 | 82 | }, |
d7a25329 | 83 | |
8cd72bd3 C |
84 | { |
85 | fields: [ 'videoId', 'resolution', 'fps' ], | |
d7a25329 C |
86 | unique: true, |
87 | where: { | |
88 | videoId: { | |
89 | [Op.ne]: null | |
90 | } | |
91 | } | |
92 | }, | |
93 | { | |
94 | fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ], | |
95 | unique: true, | |
96 | where: { | |
97 | videoStreamingPlaylistId: { | |
98 | [Op.ne]: null | |
99 | } | |
100 | } | |
93e1258c | 101 | } |
93e1258c | 102 | ] |
3fd3ab2d C |
103 | }) |
104 | export class VideoFileModel extends Model<VideoFileModel> { | |
105 | @CreatedAt | |
106 | createdAt: Date | |
107 | ||
108 | @UpdatedAt | |
109 | updatedAt: Date | |
110 | ||
111 | @AllowNull(false) | |
112 | @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution')) | |
113 | @Column | |
114 | resolution: number | |
115 | ||
116 | @AllowNull(false) | |
117 | @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size')) | |
118 | @Column(DataType.BIGINT) | |
119 | size: number | |
120 | ||
121 | @AllowNull(false) | |
14e2014a C |
122 | @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname')) |
123 | @Column | |
3fd3ab2d C |
124 | extname: string |
125 | ||
c6c0fa6c C |
126 | @AllowNull(true) |
127 | @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true)) | |
3fd3ab2d C |
128 | @Column |
129 | infoHash: string | |
130 | ||
2e7cf5ae C |
131 | @AllowNull(false) |
132 | @Default(-1) | |
3a6f351b C |
133 | @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps')) |
134 | @Column | |
135 | fps: number | |
136 | ||
8319d6ae RK |
137 | @AllowNull(true) |
138 | @Column(DataType.JSONB) | |
139 | metadata: any | |
140 | ||
141 | @AllowNull(true) | |
142 | @Column | |
143 | metadataUrl: string | |
144 | ||
3fd3ab2d C |
145 | @ForeignKey(() => VideoModel) |
146 | @Column | |
147 | videoId: number | |
148 | ||
149 | @BelongsTo(() => VideoModel, { | |
93e1258c | 150 | foreignKey: { |
d7a25329 | 151 | allowNull: true |
93e1258c C |
152 | }, |
153 | onDelete: 'CASCADE' | |
154 | }) | |
3fd3ab2d | 155 | Video: VideoModel |
cc43831a | 156 | |
d7a25329 C |
157 | @ForeignKey(() => VideoStreamingPlaylistModel) |
158 | @Column | |
159 | videoStreamingPlaylistId: number | |
160 | ||
161 | @BelongsTo(() => VideoStreamingPlaylistModel, { | |
162 | foreignKey: { | |
163 | allowNull: true | |
164 | }, | |
165 | onDelete: 'CASCADE' | |
166 | }) | |
167 | VideoStreamingPlaylist: VideoStreamingPlaylistModel | |
168 | ||
c48e82b5 C |
169 | @HasMany(() => VideoRedundancyModel, { |
170 | foreignKey: { | |
09209296 | 171 | allowNull: true |
c48e82b5 C |
172 | }, |
173 | onDelete: 'CASCADE', | |
174 | hooks: true | |
175 | }) | |
176 | RedundancyVideos: VideoRedundancyModel[] | |
177 | ||
35f28e94 C |
178 | static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist, { |
179 | promise: true, | |
180 | max: MEMOIZE_LENGTH.INFO_HASH_EXISTS, | |
181 | maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS | |
182 | }) | |
183 | ||
09209296 | 184 | static doesInfohashExist (infoHash: string) { |
cc43831a C |
185 | const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' |
186 | const options = { | |
d5d9b6d7 | 187 | type: QueryTypes.SELECT as QueryTypes.SELECT, |
cc43831a C |
188 | bind: { infoHash }, |
189 | raw: true | |
190 | } | |
191 | ||
192 | return VideoModel.sequelize.query(query, options) | |
3acc5084 | 193 | .then(results => results.length === 1) |
cc43831a | 194 | } |
e5565833 | 195 | |
8319d6ae RK |
196 | static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { |
197 | const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID) | |
7b81edc8 C |
198 | |
199 | return !!videoFile | |
8319d6ae RK |
200 | } |
201 | ||
202 | static loadWithMetadata (id: number) { | |
203 | return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id) | |
204 | } | |
205 | ||
25378bc8 | 206 | static loadWithVideo (id: number) { |
8319d6ae RK |
207 | return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id) |
208 | } | |
25378bc8 | 209 | |
8319d6ae | 210 | static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) { |
7b81edc8 C |
211 | const whereVideo = validator.isUUID(videoIdOrUUID + '') |
212 | ? { uuid: videoIdOrUUID } | |
213 | : { id: videoIdOrUUID } | |
214 | ||
215 | const options = { | |
216 | where: { | |
217 | id | |
218 | }, | |
219 | include: [ | |
220 | { | |
221 | model: VideoModel.unscoped(), | |
222 | required: false, | |
223 | where: whereVideo | |
224 | }, | |
225 | { | |
226 | model: VideoStreamingPlaylistModel.unscoped(), | |
227 | required: false, | |
228 | include: [ | |
229 | { | |
230 | model: VideoModel.unscoped(), | |
231 | required: true, | |
232 | where: whereVideo | |
233 | } | |
234 | ] | |
235 | } | |
8319d6ae | 236 | ] |
7b81edc8 C |
237 | } |
238 | ||
239 | return VideoFileModel.findOne(options) | |
240 | .then(file => { | |
241 | // We used `required: false` so check we have at least a video or a streaming playlist | |
242 | if (!file.Video && !file.VideoStreamingPlaylist) return null | |
243 | ||
244 | return file | |
245 | }) | |
25378bc8 C |
246 | } |
247 | ||
3acc5084 | 248 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { |
ae9bbed4 C |
249 | const query = { |
250 | include: [ | |
251 | { | |
252 | model: VideoModel.unscoped(), | |
253 | required: true, | |
254 | include: [ | |
255 | { | |
256 | model: VideoStreamingPlaylistModel.unscoped(), | |
257 | required: true, | |
258 | where: { | |
259 | id: streamingPlaylistId | |
260 | } | |
261 | } | |
262 | ] | |
263 | } | |
264 | ], | |
265 | transaction | |
266 | } | |
267 | ||
268 | return VideoFileModel.findAll(query) | |
269 | } | |
270 | ||
3acc5084 | 271 | static getStats () { |
0b84383d | 272 | const webtorrentFilesQuery: FindOptions = { |
44b9c0ba C |
273 | include: [ |
274 | { | |
275 | attributes: [], | |
0b84383d | 276 | required: true, |
44b9c0ba C |
277 | model: VideoModel.unscoped(), |
278 | where: { | |
279 | remote: false | |
280 | } | |
281 | } | |
282 | ] | |
44b9c0ba | 283 | } |
3acc5084 | 284 | |
0b84383d C |
285 | const hlsFilesQuery: FindOptions = { |
286 | include: [ | |
287 | { | |
288 | attributes: [], | |
289 | required: true, | |
290 | model: VideoStreamingPlaylistModel.unscoped(), | |
291 | include: [ | |
292 | { | |
293 | attributes: [], | |
294 | model: VideoModel.unscoped(), | |
295 | required: true, | |
296 | where: { | |
297 | remote: false | |
298 | } | |
299 | } | |
300 | ] | |
301 | } | |
302 | ] | |
303 | } | |
304 | ||
305 | return Promise.all([ | |
306 | VideoFileModel.aggregate('size', 'SUM', webtorrentFilesQuery), | |
307 | VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery) | |
308 | ]).then(([ webtorrentResult, hlsResult ]) => ({ | |
309 | totalLocalVideoFilesSize: parseAggregateResult(webtorrentResult) + parseAggregateResult(hlsResult) | |
310 | })) | |
44b9c0ba C |
311 | } |
312 | ||
d7a25329 C |
313 | // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes |
314 | static async customUpsert ( | |
315 | videoFile: MVideoFile, | |
316 | mode: 'streaming-playlist' | 'video', | |
317 | transaction: Transaction | |
318 | ) { | |
319 | const baseWhere = { | |
320 | fps: videoFile.fps, | |
321 | resolution: videoFile.resolution | |
322 | } | |
323 | ||
324 | if (mode === 'streaming-playlist') Object.assign(baseWhere, { videoStreamingPlaylistId: videoFile.videoStreamingPlaylistId }) | |
325 | else Object.assign(baseWhere, { videoId: videoFile.videoId }) | |
326 | ||
327 | const element = await VideoFileModel.findOne({ where: baseWhere, transaction }) | |
328 | if (!element) return videoFile.save({ transaction }) | |
329 | ||
330 | for (const k of Object.keys(videoFile.toJSON())) { | |
331 | element[k] = videoFile[k] | |
332 | } | |
333 | ||
334 | return element.save({ transaction }) | |
335 | } | |
336 | ||
97969c4e C |
337 | static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) { |
338 | const options = { | |
339 | where: { videoStreamingPlaylistId } | |
340 | } | |
341 | ||
342 | return VideoFileModel.destroy(options) | |
343 | } | |
344 | ||
d7a25329 C |
345 | getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo { |
346 | if (this.videoId) return (this as MVideoFileVideo).Video | |
347 | ||
348 | return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist | |
349 | } | |
350 | ||
536598cf C |
351 | isAudio () { |
352 | return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname] | |
353 | } | |
354 | ||
bd54ad19 C |
355 | isLive () { |
356 | return this.size === -1 | |
357 | } | |
358 | ||
053aed43 | 359 | isHLS () { |
9e2b2e76 | 360 | return !!this.videoStreamingPlaylistId |
053aed43 C |
361 | } |
362 | ||
453e83ea | 363 | hasSameUniqueKeysThan (other: MVideoFile) { |
e5565833 C |
364 | return this.fps === other.fps && |
365 | this.resolution === other.resolution && | |
d7a25329 C |
366 | ( |
367 | (this.videoId !== null && this.videoId === other.videoId) || | |
368 | (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId) | |
369 | ) | |
e5565833 | 370 | } |
93e1258c | 371 | } |