Split types and typings
[oweals/peertube.git] / server / models / video / video-file.ts
1 import {
2   AllowNull,
3   BelongsTo,
4   Column,
5   CreatedAt,
6   DataType,
7   Default,
8   ForeignKey,
9   HasMany,
10   Is,
11   Model,
12   Table,
13   UpdatedAt,
14   Scopes,
15   DefaultScope
16 } from 'sequelize-typescript'
17 import {
18   isVideoFileExtnameValid,
19   isVideoFileInfoHashValid,
20   isVideoFileResolutionValid,
21   isVideoFileSizeValid,
22   isVideoFPSResolutionValid
23 } from '../../helpers/custom-validators/videos'
24 import { parseAggregateResult, throwIfNotValid } from '../utils'
25 import { VideoModel } from './video'
26 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
27 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
28 import { FindOptions, Op, QueryTypes, Transaction } from 'sequelize'
29 import { MIMETYPES, MEMOIZE_LENGTH, MEMOIZE_TTL } from '../../initializers/constants'
30 import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
31 import { MStreamingPlaylistVideo, MVideo } from '@server/types/models'
32 import * as memoizee from 'memoizee'
33 import validator from 'validator'
34
35 export enum ScopeNames {
36   WITH_VIDEO = 'WITH_VIDEO',
37   WITH_METADATA = 'WITH_METADATA'
38 }
39
40 @DefaultScope(() => ({
41   attributes: {
42     exclude: [ 'metadata' ]
43   }
44 }))
45 @Scopes(() => ({
46   [ScopeNames.WITH_VIDEO]: {
47     include: [
48       {
49         model: VideoModel.unscoped(),
50         required: true
51       }
52     ]
53   },
54   [ScopeNames.WITH_METADATA]: {
55     attributes: {
56       include: [ 'metadata' ]
57     }
58   }
59 }))
60 @Table({
61   tableName: 'videoFile',
62   indexes: [
63     {
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       }
78     },
79
80     {
81       fields: [ 'infoHash' ]
82     },
83
84     {
85       fields: [ 'videoId', 'resolution', 'fps' ],
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       }
101     }
102   ]
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)
122   @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
123   @Column
124   extname: string
125
126   @AllowNull(false)
127   @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
128   @Column
129   infoHash: string
130
131   @AllowNull(false)
132   @Default(-1)
133   @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
134   @Column
135   fps: number
136
137   @AllowNull(true)
138   @Column(DataType.JSONB)
139   metadata: any
140
141   @AllowNull(true)
142   @Column
143   metadataUrl: string
144
145   @ForeignKey(() => VideoModel)
146   @Column
147   videoId: number
148
149   @BelongsTo(() => VideoModel, {
150     foreignKey: {
151       allowNull: true
152     },
153     onDelete: 'CASCADE'
154   })
155   Video: VideoModel
156
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
169   @HasMany(() => VideoRedundancyModel, {
170     foreignKey: {
171       allowNull: true
172     },
173     onDelete: 'CASCADE',
174     hooks: true
175   })
176   RedundancyVideos: VideoRedundancyModel[]
177
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
184   static doesInfohashExist (infoHash: string) {
185     const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
186     const options = {
187       type: QueryTypes.SELECT as QueryTypes.SELECT,
188       bind: { infoHash },
189       raw: true
190     }
191
192     return VideoModel.sequelize.query(query, options)
193               .then(results => results.length === 1)
194   }
195
196   static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
197     const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
198
199     return !!videoFile
200   }
201
202   static loadWithMetadata (id: number) {
203     return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id)
204   }
205
206   static loadWithVideo (id: number) {
207     return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id)
208   }
209
210   static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
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         }
236       ]
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       })
246   }
247
248   static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
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
271   static getStats () {
272     const query: FindOptions = {
273       include: [
274         {
275           attributes: [],
276           model: VideoModel.unscoped(),
277           where: {
278             remote: false
279           }
280         }
281       ]
282     }
283
284     return VideoFileModel.aggregate('size', 'SUM', query)
285       .then(result => ({
286         totalLocalVideoFilesSize: parseAggregateResult(result)
287       }))
288   }
289
290   // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
291   static async customUpsert (
292     videoFile: MVideoFile,
293     mode: 'streaming-playlist' | 'video',
294     transaction: Transaction
295   ) {
296     const baseWhere = {
297       fps: videoFile.fps,
298       resolution: videoFile.resolution
299     }
300
301     if (mode === 'streaming-playlist') Object.assign(baseWhere, { videoStreamingPlaylistId: videoFile.videoStreamingPlaylistId })
302     else Object.assign(baseWhere, { videoId: videoFile.videoId })
303
304     const element = await VideoFileModel.findOne({ where: baseWhere, transaction })
305     if (!element) return videoFile.save({ transaction })
306
307     for (const k of Object.keys(videoFile.toJSON())) {
308       element[k] = videoFile[k]
309     }
310
311     return element.save({ transaction })
312   }
313
314   getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
315     if (this.videoId) return (this as MVideoFileVideo).Video
316
317     return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
318   }
319
320   isAudio () {
321     return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname]
322   }
323
324   hasSameUniqueKeysThan (other: MVideoFile) {
325     return this.fps === other.fps &&
326       this.resolution === other.resolution &&
327       (
328         (this.videoId !== null && this.videoId === other.videoId) ||
329         (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)
330       )
331   }
332 }