diff options
Diffstat (limited to 'server')
-rw-r--r-- | server/controllers/api/videos/index.ts | 18 | ||||
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 33 | ||||
-rw-r--r-- | server/helpers/middlewares/videos.ts | 14 | ||||
-rw-r--r-- | server/initializers/constants.ts | 2 | ||||
-rw-r--r-- | server/initializers/migrations/0485-video-file-metadata.ts | 30 | ||||
-rw-r--r-- | server/lib/activitypub/videos.ts | 16 | ||||
-rw-r--r-- | server/lib/video-transcoding.ts | 8 | ||||
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 22 | ||||
-rw-r--r-- | server/models/redundancy/video-redundancy.ts | 6 | ||||
-rw-r--r-- | server/models/utils.ts | 18 | ||||
-rw-r--r-- | server/models/video/video-file.ts | 96 | ||||
-rw-r--r-- | server/models/video/video-format-utils.ts | 13 | ||||
-rw-r--r-- | server/models/video/video.ts | 13 | ||||
-rw-r--r-- | server/tests/api/videos/video-transcoder.ts | 73 |
14 files changed, 318 insertions, 44 deletions
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index eb46ea01f..9b19c394d 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { extname } from 'path' | 2 | import { extname } from 'path' |
3 | import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' | 3 | import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' |
4 | import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' | 4 | import { getVideoFileFPS, getVideoFileResolution, getMetadataFromFile } from '../../../helpers/ffmpeg-utils' |
5 | import { logger } from '../../../helpers/logger' | 5 | import { logger } from '../../../helpers/logger' |
6 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 6 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
7 | import { getFormattedObjects, getServerActor } from '../../../helpers/utils' | 7 | import { getFormattedObjects, getServerActor } from '../../../helpers/utils' |
@@ -37,7 +37,8 @@ import { | |||
37 | videosGetValidator, | 37 | videosGetValidator, |
38 | videosRemoveValidator, | 38 | videosRemoveValidator, |
39 | videosSortValidator, | 39 | videosSortValidator, |
40 | videosUpdateValidator | 40 | videosUpdateValidator, |
41 | videoFileMetadataGetValidator | ||
41 | } from '../../../middlewares' | 42 | } from '../../../middlewares' |
42 | import { TagModel } from '../../../models/video/tag' | 43 | import { TagModel } from '../../../models/video/tag' |
43 | import { VideoModel } from '../../../models/video/video' | 44 | import { VideoModel } from '../../../models/video/video' |
@@ -66,6 +67,7 @@ import { Hooks } from '../../../lib/plugins/hooks' | |||
66 | import { MVideoDetails, MVideoFullLight } from '@server/typings/models' | 67 | import { MVideoDetails, MVideoFullLight } from '@server/typings/models' |
67 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 68 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
68 | import { getVideoFilePath } from '@server/lib/video-paths' | 69 | import { getVideoFilePath } from '@server/lib/video-paths' |
70 | import toInt from 'validator/lib/toInt' | ||
69 | 71 | ||
70 | const auditLogger = auditLoggerFactory('videos') | 72 | const auditLogger = auditLoggerFactory('videos') |
71 | const videosRouter = express.Router() | 73 | const videosRouter = express.Router() |
@@ -128,6 +130,10 @@ videosRouter.get('/:id/description', | |||
128 | asyncMiddleware(videosGetValidator), | 130 | asyncMiddleware(videosGetValidator), |
129 | asyncMiddleware(getVideoDescription) | 131 | asyncMiddleware(getVideoDescription) |
130 | ) | 132 | ) |
133 | videosRouter.get('/:id/metadata/:videoFileId', | ||
134 | asyncMiddleware(videoFileMetadataGetValidator), | ||
135 | asyncMiddleware(getVideoFileMetadata) | ||
136 | ) | ||
131 | videosRouter.get('/:id', | 137 | videosRouter.get('/:id', |
132 | optionalAuthenticate, | 138 | optionalAuthenticate, |
133 | asyncMiddleware(videosCustomGetValidator('only-video-with-rights')), | 139 | asyncMiddleware(videosCustomGetValidator('only-video-with-rights')), |
@@ -206,7 +212,8 @@ async function addVideo (req: express.Request, res: express.Response) { | |||
206 | const videoFile = new VideoFileModel({ | 212 | const videoFile = new VideoFileModel({ |
207 | extname: extname(videoPhysicalFile.filename), | 213 | extname: extname(videoPhysicalFile.filename), |
208 | size: videoPhysicalFile.size, | 214 | size: videoPhysicalFile.size, |
209 | videoStreamingPlaylistId: null | 215 | videoStreamingPlaylistId: null, |
216 | metadata: await getMetadataFromFile<any>(videoPhysicalFile.path) | ||
210 | }) | 217 | }) |
211 | 218 | ||
212 | if (videoFile.isAudio()) { | 219 | if (videoFile.isAudio()) { |
@@ -493,6 +500,11 @@ async function getVideoDescription (req: express.Request, res: express.Response) | |||
493 | return res.json({ description }) | 500 | return res.json({ description }) |
494 | } | 501 | } |
495 | 502 | ||
503 | async function getVideoFileMetadata (req: express.Request, res: express.Response) { | ||
504 | const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId)) | ||
505 | return res.json(videoFile.metadata) | ||
506 | } | ||
507 | |||
496 | async function listVideos (req: express.Request, res: express.Response) { | 508 | async function listVideos (req: express.Request, res: express.Response) { |
497 | const countVideos = getCountVideos(req) | 509 | const countVideos = getCountVideos(req) |
498 | 510 | ||
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 084516e55..5ee295635 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -7,6 +7,7 @@ import { logger } from './logger' | |||
7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | 7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' |
8 | import { readFile, remove, writeFile } from 'fs-extra' | 8 | import { readFile, remove, writeFile } from 'fs-extra' |
9 | import { CONFIG } from '../initializers/config' | 9 | import { CONFIG } from '../initializers/config' |
10 | import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata' | ||
10 | 11 | ||
11 | /** | 12 | /** |
12 | * A toolbox to play with audio | 13 | * A toolbox to play with audio |
@@ -169,24 +170,26 @@ async function getVideoFileFPS (path: string) { | |||
169 | return 0 | 170 | return 0 |
170 | } | 171 | } |
171 | 172 | ||
172 | async function getVideoFileBitrate (path: string) { | 173 | async function getMetadataFromFile<T> (path: string, cb = metadata => metadata) { |
173 | return new Promise<number>((res, rej) => { | 174 | return new Promise<T>((res, rej) => { |
174 | ffmpeg.ffprobe(path, (err, metadata) => { | 175 | ffmpeg.ffprobe(path, (err, metadata) => { |
175 | if (err) return rej(err) | 176 | if (err) return rej(err) |
176 | 177 | ||
177 | return res(metadata.format.bit_rate) | 178 | return res(cb(new VideoFileMetadata(metadata))) |
178 | }) | 179 | }) |
179 | }) | 180 | }) |
180 | } | 181 | } |
181 | 182 | ||
183 | async function getVideoFileBitrate (path: string) { | ||
184 | return getMetadataFromFile<number>(path, metadata => metadata.format.bit_rate) | ||
185 | } | ||
186 | |||
182 | function getDurationFromVideoFile (path: string) { | 187 | function getDurationFromVideoFile (path: string) { |
183 | return new Promise<number>((res, rej) => { | 188 | return getMetadataFromFile<number>(path, metadata => Math.floor(metadata.format.duration)) |
184 | ffmpeg.ffprobe(path, (err, metadata) => { | 189 | } |
185 | if (err) return rej(err) | ||
186 | 190 | ||
187 | return res(Math.floor(metadata.format.duration)) | 191 | function getVideoStreamFromFile (path: string) { |
188 | }) | 192 | return getMetadataFromFile<any>(path, metadata => metadata.streams.find(s => s.codec_type === 'video') || null) |
189 | }) | ||
190 | } | 193 | } |
191 | 194 | ||
192 | async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { | 195 | async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { |
@@ -341,6 +344,7 @@ export { | |||
341 | getAudioStreamCodec, | 344 | getAudioStreamCodec, |
342 | getVideoStreamSize, | 345 | getVideoStreamSize, |
343 | getVideoFileResolution, | 346 | getVideoFileResolution, |
347 | getMetadataFromFile, | ||
344 | getDurationFromVideoFile, | 348 | getDurationFromVideoFile, |
345 | generateImageFromVideoFile, | 349 | generateImageFromVideoFile, |
346 | TranscodeOptions, | 350 | TranscodeOptions, |
@@ -450,17 +454,6 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { | |||
450 | await writeFile(options.outputPath, newContent) | 454 | await writeFile(options.outputPath, newContent) |
451 | } | 455 | } |
452 | 456 | ||
453 | function getVideoStreamFromFile (path: string) { | ||
454 | return new Promise<any>((res, rej) => { | ||
455 | ffmpeg.ffprobe(path, (err, metadata) => { | ||
456 | if (err) return rej(err) | ||
457 | |||
458 | const videoStream = metadata.streams.find(s => s.codec_type === 'video') | ||
459 | return res(videoStream || null) | ||
460 | }) | ||
461 | }) | ||
462 | } | ||
463 | |||
464 | /** | 457 | /** |
465 | * A slightly customised version of the 'veryfast' x264 preset | 458 | * A slightly customised version of the 'veryfast' x264 preset |
466 | * | 459 | * |
diff --git a/server/helpers/middlewares/videos.ts b/server/helpers/middlewares/videos.ts index 409f78650..a0bbcdb21 100644 --- a/server/helpers/middlewares/videos.ts +++ b/server/helpers/middlewares/videos.ts | |||
@@ -12,6 +12,7 @@ import { | |||
12 | MVideoThumbnail, | 12 | MVideoThumbnail, |
13 | MVideoWithRights | 13 | MVideoWithRights |
14 | } from '@server/typings/models' | 14 | } from '@server/typings/models' |
15 | import { VideoFileModel } from '@server/models/video/video-file' | ||
15 | 16 | ||
16 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') { | 17 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') { |
17 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined | 18 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined |
@@ -51,6 +52,18 @@ async function doesVideoExist (id: number | string, res: Response, fetchType: Vi | |||
51 | return true | 52 | return true |
52 | } | 53 | } |
53 | 54 | ||
55 | async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) { | ||
56 | if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) { | ||
57 | res.status(404) | ||
58 | .json({ error: 'VideoFile matching Video not found' }) | ||
59 | .end() | ||
60 | |||
61 | return false | ||
62 | } | ||
63 | |||
64 | return true | ||
65 | } | ||
66 | |||
54 | async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { | 67 | async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { |
55 | if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) === true) { | 68 | if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) === true) { |
56 | const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) | 69 | const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) |
@@ -107,5 +120,6 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: | |||
107 | export { | 120 | export { |
108 | doesVideoChannelOfAccountExist, | 121 | doesVideoChannelOfAccountExist, |
109 | doesVideoExist, | 122 | doesVideoExist, |
123 | doesVideoFileOfVideoExist, | ||
110 | checkUserCanManageVideo | 124 | checkUserCanManageVideo |
111 | } | 125 | } |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 3da06402c..8b040aa2c 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' | |||
14 | 14 | ||
15 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
16 | 16 | ||
17 | const LAST_MIGRATION_VERSION = 480 | 17 | const LAST_MIGRATION_VERSION = 485 |
18 | 18 | ||
19 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
20 | 20 | ||
diff --git a/server/initializers/migrations/0485-video-file-metadata.ts b/server/initializers/migrations/0485-video-file-metadata.ts new file mode 100644 index 000000000..5d95be024 --- /dev/null +++ b/server/initializers/migrations/0485-video-file-metadata.ts | |||
@@ -0,0 +1,30 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction | ||
5 | queryInterface: Sequelize.QueryInterface | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): Promise<void> { | ||
8 | |||
9 | const metadata = { | ||
10 | type: Sequelize.JSONB, | ||
11 | allowNull: true | ||
12 | } | ||
13 | await utils.queryInterface.addColumn('videoFile', 'metadata', metadata) | ||
14 | |||
15 | const metadataUrl = { | ||
16 | type: Sequelize.STRING, | ||
17 | allowNull: true | ||
18 | } | ||
19 | await utils.queryInterface.addColumn('videoFile', 'metadataUrl', metadataUrl) | ||
20 | |||
21 | } | ||
22 | |||
23 | function down (options) { | ||
24 | throw new Error('Not implemented.') | ||
25 | } | ||
26 | |||
27 | export { | ||
28 | up, | ||
29 | down | ||
30 | } | ||
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index bce1666be..30de4714c 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts | |||
@@ -10,7 +10,8 @@ import { | |||
10 | ActivityTagObject, | 10 | ActivityTagObject, |
11 | ActivityUrlObject, | 11 | ActivityUrlObject, |
12 | ActivityVideoUrlObject, | 12 | ActivityVideoUrlObject, |
13 | VideoState | 13 | VideoState, |
14 | ActivityVideoFileMetadataObject | ||
14 | } from '../../../shared/index' | 15 | } from '../../../shared/index' |
15 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' | 16 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' |
16 | import { VideoPrivacy } from '../../../shared/models/videos' | 17 | import { VideoPrivacy } from '../../../shared/models/videos' |
@@ -526,6 +527,10 @@ function isAPHashTagObject (url: any): url is ActivityHashTagObject { | |||
526 | return url && url.type === 'Hashtag' | 527 | return url && url.type === 'Hashtag' |
527 | } | 528 | } |
528 | 529 | ||
530 | function isAPVideoFileMetadataObject (url: any): url is ActivityVideoFileMetadataObject { | ||
531 | return url && url.type === 'Link' && url.mediaType === 'application/json' && url.hasAttribute('rel') && url.rel.includes('metadata') | ||
532 | } | ||
533 | |||
529 | async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) { | 534 | async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) { |
530 | logger.debug('Adding remote video %s.', videoObject.id) | 535 | logger.debug('Adding remote video %s.', videoObject.id) |
531 | 536 | ||
@@ -694,6 +699,14 @@ function videoFileActivityUrlToDBAttributes ( | |||
694 | throw new Error('Cannot parse magnet URI ' + magnet.href) | 699 | throw new Error('Cannot parse magnet URI ' + magnet.href) |
695 | } | 700 | } |
696 | 701 | ||
702 | // Fetch associated metadata url, if any | ||
703 | const metadata = urls.filter(isAPVideoFileMetadataObject) | ||
704 | .find(u => | ||
705 | u.height === fileUrl.height && | ||
706 | u.fps === fileUrl.fps && | ||
707 | u.rel.includes(fileUrl.mediaType) | ||
708 | ) | ||
709 | |||
697 | const mediaType = fileUrl.mediaType | 710 | const mediaType = fileUrl.mediaType |
698 | const attribute = { | 711 | const attribute = { |
699 | extname: MIMETYPES.VIDEO.MIMETYPE_EXT[mediaType], | 712 | extname: MIMETYPES.VIDEO.MIMETYPE_EXT[mediaType], |
@@ -701,6 +714,7 @@ function videoFileActivityUrlToDBAttributes ( | |||
701 | resolution: fileUrl.height, | 714 | resolution: fileUrl.height, |
702 | size: fileUrl.size, | 715 | size: fileUrl.size, |
703 | fps: fileUrl.fps || -1, | 716 | fps: fileUrl.fps || -1, |
717 | metadataUrl: metadata?.href, | ||
704 | 718 | ||
705 | // This is a video file owned by a video or by a streaming playlist | 719 | // This is a video file owned by a video or by a streaming playlist |
706 | videoId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id, | 720 | videoId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id, |
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index 0d5b3ae39..444b0d954 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts | |||
@@ -2,6 +2,7 @@ import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSER | |||
2 | import { basename, extname as extnameUtil, join } from 'path' | 2 | import { basename, extname as extnameUtil, join } from 'path' |
3 | import { | 3 | import { |
4 | canDoQuickTranscode, | 4 | canDoQuickTranscode, |
5 | getMetadataFromFile, | ||
5 | getDurationFromVideoFile, | 6 | getDurationFromVideoFile, |
6 | getVideoFileFPS, | 7 | getVideoFileFPS, |
7 | transcode, | 8 | transcode, |
@@ -19,6 +20,7 @@ import { CONFIG } from '../initializers/config' | |||
19 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/typings/models' | 20 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/typings/models' |
20 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 21 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
21 | import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' | 22 | import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' |
23 | import { extractVideo } from './videos' | ||
22 | 24 | ||
23 | /** | 25 | /** |
24 | * Optimize the original video file and replace it. The resolution is not changed. | 26 | * Optimize the original video file and replace it. The resolution is not changed. |
@@ -202,6 +204,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso | |||
202 | 204 | ||
203 | newVideoFile.size = stats.size | 205 | newVideoFile.size = stats.size |
204 | newVideoFile.fps = await getVideoFileFPS(videoFilePath) | 206 | newVideoFile.fps = await getVideoFileFPS(videoFilePath) |
207 | newVideoFile.metadata = await getMetadataFromFile(videoFilePath) | ||
205 | 208 | ||
206 | await createTorrentAndSetInfoHash(videoStreamingPlaylist, newVideoFile) | 209 | await createTorrentAndSetInfoHash(videoStreamingPlaylist, newVideoFile) |
207 | 210 | ||
@@ -230,11 +233,16 @@ export { | |||
230 | async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) { | 233 | async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) { |
231 | const stats = await stat(transcodingPath) | 234 | const stats = await stat(transcodingPath) |
232 | const fps = await getVideoFileFPS(transcodingPath) | 235 | const fps = await getVideoFileFPS(transcodingPath) |
236 | const metadata = await getMetadataFromFile(transcodingPath) | ||
233 | 237 | ||
234 | await move(transcodingPath, outputPath) | 238 | await move(transcodingPath, outputPath) |
235 | 239 | ||
240 | const extractedVideo = extractVideo(video) | ||
241 | |||
236 | videoFile.size = stats.size | 242 | videoFile.size = stats.size |
237 | videoFile.fps = fps | 243 | videoFile.fps = fps |
244 | videoFile.metadata = metadata | ||
245 | videoFile.metadataUrl = extractedVideo.getVideoFileMetadataUrl(videoFile, extractedVideo.getBaseUrls().baseUrlHttp) | ||
238 | 246 | ||
239 | await createTorrentAndSetInfoHash(video, videoFile) | 247 | await createTorrentAndSetInfoHash(video, videoFile) |
240 | 248 | ||
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index a027c4840..96e0d6600 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -42,7 +42,12 @@ import { getServerActor } from '../../../helpers/utils' | |||
42 | import { CONFIG } from '../../../initializers/config' | 42 | import { CONFIG } from '../../../initializers/config' |
43 | import { isLocalVideoAccepted } from '../../../lib/moderation' | 43 | import { isLocalVideoAccepted } from '../../../lib/moderation' |
44 | import { Hooks } from '../../../lib/plugins/hooks' | 44 | import { Hooks } from '../../../lib/plugins/hooks' |
45 | import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '../../../helpers/middlewares' | 45 | import { |
46 | checkUserCanManageVideo, | ||
47 | doesVideoChannelOfAccountExist, | ||
48 | doesVideoExist, | ||
49 | doesVideoFileOfVideoExist | ||
50 | } from '../../../helpers/middlewares' | ||
46 | import { MVideoFullLight } from '@server/typings/models' | 51 | import { MVideoFullLight } from '@server/typings/models' |
47 | import { getVideoWithAttributes } from '../../../helpers/video' | 52 | import { getVideoWithAttributes } from '../../../helpers/video' |
48 | 53 | ||
@@ -198,6 +203,20 @@ const videosCustomGetValidator = ( | |||
198 | const videosGetValidator = videosCustomGetValidator('all') | 203 | const videosGetValidator = videosCustomGetValidator('all') |
199 | const videosDownloadValidator = videosCustomGetValidator('all', true) | 204 | const videosDownloadValidator = videosCustomGetValidator('all', true) |
200 | 205 | ||
206 | const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ | ||
207 | param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), | ||
208 | param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'), | ||
209 | |||
210 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
211 | logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params }) | ||
212 | |||
213 | if (areValidationErrors(req, res)) return | ||
214 | if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return | ||
215 | |||
216 | return next() | ||
217 | } | ||
218 | ]) | ||
219 | |||
201 | const videosRemoveValidator = [ | 220 | const videosRemoveValidator = [ |
202 | param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), | 221 | param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), |
203 | 222 | ||
@@ -411,6 +430,7 @@ export { | |||
411 | videosAddValidator, | 430 | videosAddValidator, |
412 | videosUpdateValidator, | 431 | videosUpdateValidator, |
413 | videosGetValidator, | 432 | videosGetValidator, |
433 | videoFileMetadataGetValidator, | ||
414 | videosDownloadValidator, | 434 | videosDownloadValidator, |
415 | checkVideoFollowConstraints, | 435 | checkVideoFollowConstraints, |
416 | videosCustomGetValidator, | 436 | videosCustomGetValidator, |
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts index 1b63d3818..857b9eca6 100644 --- a/server/models/redundancy/video-redundancy.ts +++ b/server/models/redundancy/video-redundancy.ts | |||
@@ -528,7 +528,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
528 | include: [ | 528 | include: [ |
529 | { | 529 | { |
530 | required: false, | 530 | required: false, |
531 | model: VideoFileModel.unscoped(), | 531 | model: VideoFileModel, |
532 | include: [ | 532 | include: [ |
533 | { | 533 | { |
534 | model: VideoRedundancyModel.unscoped(), | 534 | model: VideoRedundancyModel.unscoped(), |
@@ -547,7 +547,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
547 | where: redundancyWhere | 547 | where: redundancyWhere |
548 | }, | 548 | }, |
549 | { | 549 | { |
550 | model: VideoFileModel.unscoped(), | 550 | model: VideoFileModel, |
551 | required: false | 551 | required: false |
552 | } | 552 | } |
553 | ] | 553 | ] |
@@ -699,7 +699,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
699 | 699 | ||
700 | return { | 700 | return { |
701 | attributes: [], | 701 | attributes: [], |
702 | model: VideoFileModel.unscoped(), | 702 | model: VideoFileModel, |
703 | required: true, | 703 | required: true, |
704 | where: { | 704 | where: { |
705 | id: { | 705 | id: { |
diff --git a/server/models/utils.ts b/server/models/utils.ts index 674ddcbe4..06ff05864 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts | |||
@@ -3,6 +3,23 @@ import validator from 'validator' | |||
3 | import { Col } from 'sequelize/types/lib/utils' | 3 | import { Col } from 'sequelize/types/lib/utils' |
4 | import { literal, OrderItem } from 'sequelize' | 4 | import { literal, OrderItem } from 'sequelize' |
5 | 5 | ||
6 | type Primitive = string | Function | number | boolean | Symbol | undefined | null | ||
7 | type DeepOmitHelper<T, K extends keyof T> = { | ||
8 | [P in K]: // extra level of indirection needed to trigger homomorhic behavior | ||
9 | T[P] extends infer TP // distribute over unions | ||
10 | ? TP extends Primitive | ||
11 | ? TP // leave primitives and functions alone | ||
12 | : TP extends any[] | ||
13 | ? DeepOmitArray<TP, K> // Array special handling | ||
14 | : DeepOmit<TP, K> | ||
15 | : never | ||
16 | } | ||
17 | type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>> | ||
18 | |||
19 | type DeepOmitArray<T extends any[], K> = { | ||
20 | [P in keyof T]: DeepOmit<T[P], K> | ||
21 | } | ||
22 | |||
6 | type SortType = { sortModel: string, sortValue: string } | 23 | type SortType = { sortModel: string, sortValue: string } |
7 | 24 | ||
8 | // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] | 25 | // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] |
@@ -193,6 +210,7 @@ function buildDirectionAndField (value: string) { | |||
193 | // --------------------------------------------------------------------------- | 210 | // --------------------------------------------------------------------------- |
194 | 211 | ||
195 | export { | 212 | export { |
213 | DeepOmit, | ||
196 | buildBlockedAccountSQL, | 214 | buildBlockedAccountSQL, |
197 | buildLocalActorIdsIn, | 215 | buildLocalActorIdsIn, |
198 | SortType, | 216 | SortType, |
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index e08999385..029468004 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts | |||
@@ -10,7 +10,9 @@ import { | |||
10 | Is, | 10 | Is, |
11 | Model, | 11 | Model, |
12 | Table, | 12 | Table, |
13 | UpdatedAt | 13 | UpdatedAt, |
14 | Scopes, | ||
15 | DefaultScope | ||
14 | } from 'sequelize-typescript' | 16 | } from 'sequelize-typescript' |
15 | import { | 17 | import { |
16 | isVideoFileExtnameValid, | 18 | isVideoFileExtnameValid, |
@@ -29,6 +31,60 @@ import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '. | |||
29 | import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' | 31 | import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' |
30 | import * as memoizee from 'memoizee' | 32 | import * as memoizee from 'memoizee' |
31 | 33 | ||
34 | export enum ScopeNames { | ||
35 | WITH_VIDEO = 'WITH_VIDEO', | ||
36 | WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST', | ||
37 | WITH_METADATA = 'WITH_METADATA' | ||
38 | } | ||
39 | |||
40 | const METADATA_FIELDS = [ 'metadata', 'metadataUrl' ] | ||
41 | |||
42 | @DefaultScope(() => ({ | ||
43 | attributes: { | ||
44 | exclude: [ METADATA_FIELDS[0] ] | ||
45 | } | ||
46 | })) | ||
47 | @Scopes(() => ({ | ||
48 | [ScopeNames.WITH_VIDEO]: { | ||
49 | include: [ | ||
50 | { | ||
51 | model: VideoModel.unscoped(), | ||
52 | required: true | ||
53 | } | ||
54 | ] | ||
55 | }, | ||
56 | [ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (videoIdOrUUID: string | number) => { | ||
57 | const where = (typeof videoIdOrUUID === 'number') | ||
58 | ? { id: videoIdOrUUID } | ||
59 | : { uuid: videoIdOrUUID } | ||
60 | |||
61 | return { | ||
62 | include: [ | ||
63 | { | ||
64 | model: VideoModel.unscoped(), | ||
65 | required: false, | ||
66 | where | ||
67 | }, | ||
68 | { | ||
69 | model: VideoStreamingPlaylistModel.unscoped(), | ||
70 | required: false, | ||
71 | include: [ | ||
72 | { | ||
73 | model: VideoModel.unscoped(), | ||
74 | required: true, | ||
75 | where | ||
76 | } | ||
77 | ] | ||
78 | } | ||
79 | ] | ||
80 | } | ||
81 | }, | ||
82 | [ScopeNames.WITH_METADATA]: { | ||
83 | attributes: { | ||
84 | include: METADATA_FIELDS | ||
85 | } | ||
86 | } | ||
87 | })) | ||
32 | @Table({ | 88 | @Table({ |
33 | tableName: 'videoFile', | 89 | tableName: 'videoFile', |
34 | indexes: [ | 90 | indexes: [ |
@@ -106,6 +162,14 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
106 | @Column | 162 | @Column |
107 | fps: number | 163 | fps: number |
108 | 164 | ||
165 | @AllowNull(true) | ||
166 | @Column(DataType.JSONB) | ||
167 | metadata: any | ||
168 | |||
169 | @AllowNull(true) | ||
170 | @Column | ||
171 | metadataUrl: string | ||
172 | |||
109 | @ForeignKey(() => VideoModel) | 173 | @ForeignKey(() => VideoModel) |
110 | @Column | 174 | @Column |
111 | videoId: number | 175 | videoId: number |
@@ -157,17 +221,29 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
157 | .then(results => results.length === 1) | 221 | .then(results => results.length === 1) |
158 | } | 222 | } |
159 | 223 | ||
224 | static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { | ||
225 | const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID) | ||
226 | return (videoFile?.Video.id === videoIdOrUUID) || | ||
227 | (videoFile?.Video.uuid === videoIdOrUUID) || | ||
228 | (videoFile?.VideoStreamingPlaylist?.Video?.id === videoIdOrUUID) || | ||
229 | (videoFile?.VideoStreamingPlaylist?.Video?.uuid === videoIdOrUUID) | ||
230 | } | ||
231 | |||
232 | static loadWithMetadata (id: number) { | ||
233 | return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id) | ||
234 | } | ||
235 | |||
160 | static loadWithVideo (id: number) { | 236 | static loadWithVideo (id: number) { |
161 | const options = { | 237 | return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id) |
162 | include: [ | 238 | } |
163 | { | ||
164 | model: VideoModel.unscoped(), | ||
165 | required: true | ||
166 | } | ||
167 | ] | ||
168 | } | ||
169 | 239 | ||
170 | return VideoFileModel.findByPk(id, options) | 240 | static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) { |
241 | return VideoFileModel.scope({ | ||
242 | method: [ | ||
243 | ScopeNames.WITH_VIDEO_OR_PLAYLIST, | ||
244 | videoIdOrUUID | ||
245 | ] | ||
246 | }).findByPk(id) | ||
171 | } | 247 | } |
172 | 248 | ||
173 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { | 249 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { |
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index 1fa66fd63..21f0e0a68 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -23,6 +23,7 @@ import { | |||
23 | import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' | 23 | import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' |
24 | import { VideoFile } from '@shared/models/videos/video-file.model' | 24 | import { VideoFile } from '@shared/models/videos/video-file.model' |
25 | import { generateMagnetUri } from '@server/helpers/webtorrent' | 25 | import { generateMagnetUri } from '@server/helpers/webtorrent' |
26 | import { extractVideo } from '@server/lib/videos' | ||
26 | 27 | ||
27 | export type VideoFormattingJSONOptions = { | 28 | export type VideoFormattingJSONOptions = { |
28 | completeDescription?: boolean | 29 | completeDescription?: boolean |
@@ -193,7 +194,8 @@ function videoFilesModelToFormattedJSON ( | |||
193 | torrentUrl: model.getTorrentUrl(videoFile, baseUrlHttp), | 194 | torrentUrl: model.getTorrentUrl(videoFile, baseUrlHttp), |
194 | torrentDownloadUrl: model.getTorrentDownloadUrl(videoFile, baseUrlHttp), | 195 | torrentDownloadUrl: model.getTorrentDownloadUrl(videoFile, baseUrlHttp), |
195 | fileUrl: model.getVideoFileUrl(videoFile, baseUrlHttp), | 196 | fileUrl: model.getVideoFileUrl(videoFile, baseUrlHttp), |
196 | fileDownloadUrl: model.getVideoFileDownloadUrl(videoFile, baseUrlHttp) | 197 | fileDownloadUrl: model.getVideoFileDownloadUrl(videoFile, baseUrlHttp), |
198 | metadataUrl: videoFile.metadataUrl // only send the metadataUrl and not the metadata over the wire | ||
197 | } as VideoFile | 199 | } as VideoFile |
198 | }) | 200 | }) |
199 | .sort((a, b) => { | 201 | .sort((a, b) => { |
@@ -222,6 +224,15 @@ function addVideoFilesInAPAcc ( | |||
222 | 224 | ||
223 | acc.push({ | 225 | acc.push({ |
224 | type: 'Link', | 226 | type: 'Link', |
227 | rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], | ||
228 | mediaType: 'application/json' as 'application/json', | ||
229 | href: extractVideo(model).getVideoFileMetadataUrl(file, baseUrlHttp), | ||
230 | height: file.resolution, | ||
231 | fps: file.fps | ||
232 | }) | ||
233 | |||
234 | acc.push({ | ||
235 | type: 'Link', | ||
225 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', | 236 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', |
226 | href: model.getTorrentUrl(file, baseUrlHttp), | 237 | href: model.getTorrentUrl(file, baseUrlHttp), |
227 | height: file.resolution | 238 | height: file.resolution |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 7f94e834a..5e4b7d44c 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -216,7 +216,7 @@ export type AvailableForListIDsOptions = { | |||
216 | 216 | ||
217 | if (options.withFiles === true) { | 217 | if (options.withFiles === true) { |
218 | query.include.push({ | 218 | query.include.push({ |
219 | model: VideoFileModel.unscoped(), | 219 | model: VideoFileModel, |
220 | required: true | 220 | required: true |
221 | }) | 221 | }) |
222 | } | 222 | } |
@@ -337,7 +337,7 @@ export type AvailableForListIDsOptions = { | |||
337 | return { | 337 | return { |
338 | include: [ | 338 | include: [ |
339 | { | 339 | { |
340 | model: VideoFileModel.unscoped(), | 340 | model: VideoFileModel, |
341 | separate: true, // We may have multiple files, having multiple redundancies so let's separate this join | 341 | separate: true, // We may have multiple files, having multiple redundancies so let's separate this join |
342 | required: false, | 342 | required: false, |
343 | include: subInclude | 343 | include: subInclude |
@@ -348,7 +348,7 @@ export type AvailableForListIDsOptions = { | |||
348 | [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => { | 348 | [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => { |
349 | const subInclude: IncludeOptions[] = [ | 349 | const subInclude: IncludeOptions[] = [ |
350 | { | 350 | { |
351 | model: VideoFileModel.unscoped(), | 351 | model: VideoFileModel, |
352 | required: false | 352 | required: false |
353 | } | 353 | } |
354 | ] | 354 | ] |
@@ -1847,6 +1847,13 @@ export class VideoModel extends Model<VideoModel> { | |||
1847 | return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) | 1847 | return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) |
1848 | } | 1848 | } |
1849 | 1849 | ||
1850 | getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) { | ||
1851 | const path = '/api/v1/videos/' | ||
1852 | return videoFile.metadata | ||
1853 | ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id | ||
1854 | : videoFile.metadataUrl | ||
1855 | } | ||
1856 | |||
1850 | getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { | 1857 | getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { |
1851 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) | 1858 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) |
1852 | } | 1859 | } |
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index 3e73ccbfa..ce0dd14d5 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts | |||
@@ -4,7 +4,14 @@ import * as chai from 'chai' | |||
4 | import 'mocha' | 4 | import 'mocha' |
5 | import { omit } from 'lodash' | 5 | import { omit } from 'lodash' |
6 | import { getMaxBitrate, VideoDetails, VideoResolution, VideoState } from '../../../../shared/models/videos' | 6 | import { getMaxBitrate, VideoDetails, VideoResolution, VideoState } from '../../../../shared/models/videos' |
7 | import { audio, canDoQuickTranscode, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' | 7 | import { |
8 | audio, | ||
9 | canDoQuickTranscode, | ||
10 | getVideoFileBitrate, | ||
11 | getVideoFileFPS, | ||
12 | getVideoFileResolution, | ||
13 | getMetadataFromFile | ||
14 | } from '../../../helpers/ffmpeg-utils' | ||
8 | import { | 15 | import { |
9 | buildAbsoluteFixturePath, | 16 | buildAbsoluteFixturePath, |
10 | cleanupTests, | 17 | cleanupTests, |
@@ -14,6 +21,7 @@ import { | |||
14 | generateVideoWithFramerate, | 21 | generateVideoWithFramerate, |
15 | getMyVideos, | 22 | getMyVideos, |
16 | getVideo, | 23 | getVideo, |
24 | getVideoFileMetadataUrl, | ||
17 | getVideosList, | 25 | getVideosList, |
18 | makeGetRequest, | 26 | makeGetRequest, |
19 | root, | 27 | root, |
@@ -25,6 +33,7 @@ import { | |||
25 | } from '../../../../shared/extra-utils' | 33 | } from '../../../../shared/extra-utils' |
26 | import { join } from 'path' | 34 | import { join } from 'path' |
27 | import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants' | 35 | import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants' |
36 | import { FfprobeData } from 'fluent-ffmpeg' | ||
28 | 37 | ||
29 | const expect = chai.expect | 38 | const expect = chai.expect |
30 | 39 | ||
@@ -458,6 +467,68 @@ describe('Test video transcoding', function () { | |||
458 | } | 467 | } |
459 | }) | 468 | }) |
460 | 469 | ||
470 | it('Should provide valid ffprobe data', async function () { | ||
471 | this.timeout(160000) | ||
472 | |||
473 | const videoAttributes = { | ||
474 | name: 'my super name for server 1', | ||
475 | description: 'my super description for server 1', | ||
476 | fixture: 'video_short.webm' | ||
477 | } | ||
478 | await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes) | ||
479 | |||
480 | await waitJobs(servers) | ||
481 | |||
482 | const res = await getVideosList(servers[1].url) | ||
483 | |||
484 | const videoOnOrigin = res.body.data.find(v => v.name === videoAttributes.name) | ||
485 | const res2 = await getVideo(servers[1].url, videoOnOrigin.id) | ||
486 | const videoOnOriginDetails: VideoDetails = res2.body | ||
487 | |||
488 | { | ||
489 | const path = join(root(), 'test' + servers[1].internalServerNumber, 'videos', videoOnOrigin.uuid + '-240.mp4') | ||
490 | const metadata = await getMetadataFromFile(path) | ||
491 | for (const p of [ | ||
492 | // expected format properties | ||
493 | 'format.encoder', | ||
494 | 'format.format_long_name', | ||
495 | 'format.size', | ||
496 | 'format.bit_rate', | ||
497 | // expected stream properties | ||
498 | 'stream[0].codec_long_name', | ||
499 | 'stream[0].profile', | ||
500 | 'stream[0].width', | ||
501 | 'stream[0].height', | ||
502 | 'stream[0].display_aspect_ratio', | ||
503 | 'stream[0].avg_frame_rate', | ||
504 | 'stream[0].pix_fmt' | ||
505 | ]) { | ||
506 | expect(metadata).to.have.nested.property(p) | ||
507 | } | ||
508 | expect(metadata).to.not.have.nested.property('format.filename') | ||
509 | } | ||
510 | |||
511 | for (const server of servers) { | ||
512 | const res = await getVideosList(server.url) | ||
513 | |||
514 | const video = res.body.data.find(v => v.name === videoAttributes.name) | ||
515 | const res2 = await getVideo(server.url, video.id) | ||
516 | const videoDetails = res2.body | ||
517 | |||
518 | const videoFiles = videoDetails.files | ||
519 | for (const [ index, file ] of videoFiles.entries()) { | ||
520 | expect(file.metadata).to.be.undefined | ||
521 | expect(file.metadataUrl).to.contain(servers[1].url) | ||
522 | expect(file.metadataUrl).to.contain(videoOnOrigin.uuid) | ||
523 | |||
524 | const res3 = await getVideoFileMetadataUrl(file.metadataUrl) | ||
525 | const metadata: FfprobeData = res3.body | ||
526 | expect(metadata).to.have.nested.property('format.size') | ||
527 | expect(metadata.format.size).to.equal(videoOnOriginDetails.files[index].metadata.format.size) | ||
528 | } | ||
529 | } | ||
530 | }) | ||
531 | |||
461 | after(async function () { | 532 | after(async function () { |
462 | await cleanupTests(servers) | 533 | await cleanupTests(servers) |
463 | }) | 534 | }) |