aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html38
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss35
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts109
-rw-r--r--client/src/app/shared/video/video.service.ts9
-rw-r--r--client/src/sass/bootstrap.scss7
-rw-r--r--server/controllers/api/videos/index.ts18
-rw-r--r--server/helpers/ffmpeg-utils.ts33
-rw-r--r--server/helpers/middlewares/videos.ts14
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0485-video-file-metadata.ts30
-rw-r--r--server/lib/activitypub/videos.ts16
-rw-r--r--server/lib/video-transcoding.ts8
-rw-r--r--server/middlewares/validators/videos/videos.ts22
-rw-r--r--server/models/redundancy/video-redundancy.ts6
-rw-r--r--server/models/utils.ts18
-rw-r--r--server/models/video/video-file.ts96
-rw-r--r--server/models/video/video-format-utils.ts13
-rw-r--r--server/models/video/video.ts13
-rw-r--r--server/tests/api/videos/video-transcoder.ts73
-rw-r--r--shared/extra-utils/videos/videos.ts9
-rw-r--r--shared/models/activitypub/objects/common-objects.ts11
-rw-r--r--shared/models/videos/video-file-metadata.ts18
-rw-r--r--shared/models/videos/video-file.model.ts3
23 files changed, 551 insertions, 50 deletions
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
index 976da03f3..391fe245e 100644
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -20,7 +20,7 @@
20 <div class="form-group"> 20 <div class="form-group">
21 <div class="input-group input-group-sm"> 21 <div class="input-group input-group-sm">
22 <div class="input-group-prepend peertube-select-container"> 22 <div class="input-group-prepend peertube-select-container">
23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId"> 23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> 24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
25 </select> 25 </select>
26 26
@@ -38,6 +38,42 @@
38 </div> 38 </div>
39 </div> 39 </div>
40 40
41 <ngb-tabset *ngIf="type === 'video' && videoFile?.metadata">
42 <ngb-tab>
43 <ng-template ngbTabTitle i18n>Format</ng-template>
44 <ng-template ngbTabContent>
45 <div class="file-metadata">
46 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
47 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
48 <span class="metadata-attribute-value">{{ item.value.value }}</span>
49 </div>
50 </div>
51 </ng-template>
52 </ngb-tab>
53 <ngb-tab [disabled]="videoFileMetadataVideoStream === undefined">
54 <ng-template ngbTabTitle i18n>Video stream</ng-template>
55 <ng-template ngbTabContent>
56 <div class="file-metadata">
57 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
58 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
59 <span class="metadata-attribute-value">{{ item.value.value }}</span>
60 </div>
61 </div>
62 </ng-template>
63 </ngb-tab>
64 <ngb-tab [disabled]="videoFileMetadataAudioStream === undefined">
65 <ng-template ngbTabTitle i18n>Audio stream</ng-template>
66 <ng-template ngbTabContent>
67 <div class="file-metadata">
68 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
69 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
70 <span class="metadata-attribute-value">{{ item.value.value }}</span>
71 </div>
72 </div>
73 </ng-template>
74 </ngb-tab>
75 </ngb-tabset>
76
41 <div class="download-type" *ngIf="type === 'video'"> 77 <div class="download-type" *ngIf="type === 'video'">
42 <div class="peertube-radio-container"> 78 <div class="peertube-radio-container">
43 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> 79 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
index 09dd91aa9..f28bc34ed 100644
--- a/client/src/app/shared/video/modals/video-download.component.scss
+++ b/client/src/app/shared/video/modals/video-download.component.scss
@@ -27,3 +27,38 @@
27 margin-right: 30px; 27 margin-right: 30px;
28 } 28 }
29} 29}
30
31.file-metadata {
32 padding: 1rem;
33}
34
35.file-metadata .metadata-attribute {
36 font-size: 13px;
37 display: block;
38 margin-bottom: 12px;
39
40 .metadata-attribute-label {
41 min-width: 142px;
42 padding-right: 5px;
43 display: inline-block;
44 color: $grey-foreground-color;
45 font-weight: $font-bold;
46 }
47
48 a.metadata-attribute-value {
49 @include disable-default-a-behaviour;
50 color: var(--mainForegroundColor);
51
52 &:hover {
53 opacity: 0.9;
54 }
55 }
56
57 &.metadata-attribute-tags {
58 .metadata-attribute-value:not(:nth-child(2)) {
59 &::before {
60 content: ', '
61 }
62 }
63 }
64}
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
index 6909c4279..d77187821 100644
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -3,9 +3,15 @@ import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { AuthService, Notifier } from '@app/core' 5import { AuthService, Notifier } from '@app/core'
6import { VideoPrivacy, VideoCaption } from '@shared/models' 6import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models'
7import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
8import { mapValues, pick } from 'lodash-es'
9import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
10import { BytesPipe } from 'ngx-pipes'
11import { VideoService } from '../video.service'
7 12
8type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
14type FileMetadata = { [key: string]: { label: string, value: string }}
9 15
10@Component({ 16@Component({
11 selector: 'my-video-download', 17 selector: 'my-video-download',
@@ -20,17 +26,28 @@ export class VideoDownloadComponent {
20 subtitleLanguageId: string 26 subtitleLanguageId: string
21 27
22 video: VideoDetails 28 video: VideoDetails
29 videoFile: VideoFile
30 videoFileMetadataFormat: FileMetadata
31 videoFileMetadataVideoStream: FileMetadata | undefined
32 videoFileMetadataAudioStream: FileMetadata | undefined
23 videoCaptions: VideoCaption[] 33 videoCaptions: VideoCaption[]
24 activeModal: NgbActiveModal 34 activeModal: NgbActiveModal
25 35
26 type: DownloadType = 'video' 36 type: DownloadType = 'video'
27 37
38 private bytesPipe: BytesPipe
39 private numbersPipe: NumberFormatterPipe
40
28 constructor ( 41 constructor (
29 private notifier: Notifier, 42 private notifier: Notifier,
30 private modalService: NgbModal, 43 private modalService: NgbModal,
44 private videoService: VideoService,
31 private auth: AuthService, 45 private auth: AuthService,
32 private i18n: I18n 46 private i18n: I18n
33 ) { } 47 ) {
48 this.bytesPipe = new BytesPipe()
49 this.numbersPipe = new NumberFormatterPipe()
50 }
34 51
35 get typeText () { 52 get typeText () {
36 return this.type === 'video' 53 return this.type === 'video'
@@ -51,6 +68,7 @@ export class VideoDownloadComponent {
51 this.activeModal = this.modalService.open(this.modal, { centered: true }) 68 this.activeModal = this.modalService.open(this.modal, { centered: true })
52 69
53 this.resolutionId = this.getVideoFiles()[0].resolution.id 70 this.resolutionId = this.getVideoFiles()[0].resolution.id
71 this.onResolutionIdChange()
54 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id 72 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
55 } 73 }
56 74
@@ -67,10 +85,27 @@ export class VideoDownloadComponent {
67 getLink () { 85 getLink () {
68 return this.type === 'subtitles' && this.videoCaptions 86 return this.type === 'subtitles' && this.videoCaptions
69 ? this.getSubtitlesLink() 87 ? this.getSubtitlesLink()
70 : this.getVideoLink() 88 : this.getVideoFileLink()
71 } 89 }
72 90
73 getVideoLink () { 91 async onResolutionIdChange () {
92 this.videoFile = this.getVideoFile()
93 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
94
95 await this.hydrateMetadataFromMetadataUrl(this.videoFile)
96
97 this.videoFileMetadataFormat = this.videoFile
98 ? this.getMetadataFormat(this.videoFile.metadata.format)
99 : undefined
100 this.videoFileMetadataVideoStream = this.videoFile
101 ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
102 : undefined
103 this.videoFileMetadataAudioStream = this.videoFile
104 ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
105 : undefined
106 }
107
108 getVideoFile () {
74 // HTML select send us a string, so convert it to a number 109 // HTML select send us a string, so convert it to a number
75 this.resolutionId = parseInt(this.resolutionId.toString(), 10) 110 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
76 111
@@ -79,6 +114,12 @@ export class VideoDownloadComponent {
79 console.error('Could not find file with resolution %d.', this.resolutionId) 114 console.error('Could not find file with resolution %d.', this.resolutionId)
80 return 115 return
81 } 116 }
117 return file
118 }
119
120 getVideoFileLink () {
121 const file = this.videoFile
122 if (!file) return
82 123
83 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 124 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
84 ? '?access_token=' + this.auth.getAccessToken() 125 ? '?access_token=' + this.auth.getAccessToken()
@@ -104,4 +145,64 @@ export class VideoDownloadComponent {
104 switchToType (type: DownloadType) { 145 switchToType (type: DownloadType) {
105 this.type = type 146 this.type = type
106 } 147 }
148
149 getMetadataFormat (format: FfprobeFormat) {
150 const keyToTranslateFunction = {
151 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
152 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
153 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
154 'bit_rate': (value: number) => ({
155 label: this.i18n('Bitrate'),
156 value: `${this.numbersPipe.transform(value)}bps`
157 })
158 }
159
160 // flattening format
161 const sanitizedFormat = Object.assign(format, format.tags)
162 delete sanitizedFormat.tags
163
164 return mapValues(
165 pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
166 (val, key) => keyToTranslateFunction[key](val)
167 )
168 }
169
170 getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
171 const stream = streams.find(s => s.codec_type === type)
172 if (!stream) return undefined
173
174 let keyToTranslateFunction = {
175 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
176 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
177 'bit_rate': (value: number) => ({
178 label: this.i18n('Bitrate'),
179 value: `${this.numbersPipe.transform(value)}bps`
180 })
181 }
182
183 if (type === 'video') {
184 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
185 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
186 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
187 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
188 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
189 })
190 } else {
191 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
192 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
193 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
194 })
195 }
196
197 return mapValues(
198 pick(stream, Object.keys(keyToTranslateFunction)),
199 (val, key) => keyToTranslateFunction[key](val)
200 )
201 }
202
203 private hydrateMetadataFromMetadataUrl (file: VideoFile) {
204 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
205 observable.subscribe(res => file.metadata = res)
206 return observable.toPromise()
207 }
107} 208}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index a51b9cab9..3aaf14990 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -32,6 +32,7 @@ import { UserSubscriptionService } from '@app/shared/user-subscription/user-subs
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' 34import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
35import { FfprobeData } from 'fluent-ffmpeg'
35 36
36export interface VideosProvider { 37export interface VideosProvider {
37 getVideos (parameters: { 38 getVideos (parameters: {
@@ -291,6 +292,14 @@ export class VideoService implements VideosProvider {
291 return this.buildBaseFeedUrls(params) 292 return this.buildBaseFeedUrls(params)
292 } 293 }
293 294
295 getVideoFileMetadata (metadataUrl: string) {
296 return this.authHttp
297 .get<FfprobeData>(metadataUrl)
298 .pipe(
299 catchError(err => this.restExtractor.handleError(err))
300 )
301 }
302
294 removeVideo (id: number) { 303 removeVideo (id: number) {
295 return this.authHttp 304 return this.authHttp
296 .delete(VideoService.BASE_VIDEO_URL + id) 305 .delete(VideoService.BASE_VIDEO_URL + id)
diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss
index e167fd02b..f718791eb 100644
--- a/client/src/sass/bootstrap.scss
+++ b/client/src/sass/bootstrap.scss
@@ -109,6 +109,11 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
109 margin: 0; 109 margin: 0;
110 padding: 0; 110 padding: 0;
111 opacity: .5; 111 opacity: .5;
112
113 &[iconName="cross"] {
114 @include icon(16px);
115 top: -3px;
116 }
112 } 117 }
113 } 118 }
114 119
@@ -153,7 +158,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
153 } 158 }
154} 159}
155 160
156ngb-tabset.bootstrap { 161ngb-tabset {
157 162
158 .nav-link { 163 .nav-link {
159 &, & a { 164 &, & a {
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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { extname } from 'path' 2import { extname } from 'path'
3import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' 3import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared'
4import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' 4import { getVideoFileFPS, getVideoFileResolution, getMetadataFromFile } from '../../../helpers/ffmpeg-utils'
5import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
6import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 6import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
7import { getFormattedObjects, getServerActor } from '../../../helpers/utils' 7import { 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'
42import { TagModel } from '../../../models/video/tag' 43import { TagModel } from '../../../models/video/tag'
43import { VideoModel } from '../../../models/video/video' 44import { VideoModel } from '../../../models/video/video'
@@ -66,6 +67,7 @@ import { Hooks } from '../../../lib/plugins/hooks'
66import { MVideoDetails, MVideoFullLight } from '@server/typings/models' 67import { MVideoDetails, MVideoFullLight } from '@server/typings/models'
67import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 68import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
68import { getVideoFilePath } from '@server/lib/video-paths' 69import { getVideoFilePath } from '@server/lib/video-paths'
70import toInt from 'validator/lib/toInt'
69 71
70const auditLogger = auditLoggerFactory('videos') 72const auditLogger = auditLoggerFactory('videos')
71const videosRouter = express.Router() 73const 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)
133videosRouter.get('/:id/metadata/:videoFileId',
134 asyncMiddleware(videoFileMetadataGetValidator),
135 asyncMiddleware(getVideoFileMetadata)
136)
131videosRouter.get('/:id', 137videosRouter.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
503async 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
496async function listVideos (req: express.Request, res: express.Response) { 508async 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'
7import { checkFFmpegEncoders } from '../initializers/checker-before-init' 7import { checkFFmpegEncoders } from '../initializers/checker-before-init'
8import { readFile, remove, writeFile } from 'fs-extra' 8import { readFile, remove, writeFile } from 'fs-extra'
9import { CONFIG } from '../initializers/config' 9import { CONFIG } from '../initializers/config'
10import { 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
172async function getVideoFileBitrate (path: string) { 173async 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
183async function getVideoFileBitrate (path: string) {
184 return getMetadataFromFile<number>(path, metadata => metadata.format.bit_rate)
185}
186
182function getDurationFromVideoFile (path: string) { 187function 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)) 191function getVideoStreamFromFile (path: string) {
188 }) 192 return getMetadataFromFile<any>(path, metadata => metadata.streams.find(s => s.codec_type === 'video') || null)
189 })
190} 193}
191 194
192async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { 195async 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
453function 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'
15import { VideoFileModel } from '@server/models/video/video-file'
15 16
16async function doesVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') { 17async 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
55async 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
54async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { 67async 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:
107export { 120export {
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
17const LAST_MIGRATION_VERSION = 480 17const 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 @@
1import * as Sequelize from 'sequelize'
2
3async 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
23function down (options) {
24 throw new Error('Not implemented.')
25}
26
27export {
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'
15import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 16import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
16import { VideoPrivacy } from '../../../shared/models/videos' 17import { 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
530function 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
529async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) { 534async 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
2import { basename, extname as extnameUtil, join } from 'path' 2import { basename, extname as extnameUtil, join } from 'path'
3import { 3import {
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'
19import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/typings/models' 20import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/typings/models'
20import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 21import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
21import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' 22import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths'
23import { 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 {
230async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) { 233async 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'
42import { CONFIG } from '../../../initializers/config' 42import { CONFIG } from '../../../initializers/config'
43import { isLocalVideoAccepted } from '../../../lib/moderation' 43import { isLocalVideoAccepted } from '../../../lib/moderation'
44import { Hooks } from '../../../lib/plugins/hooks' 44import { Hooks } from '../../../lib/plugins/hooks'
45import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '../../../helpers/middlewares' 45import {
46 checkUserCanManageVideo,
47 doesVideoChannelOfAccountExist,
48 doesVideoExist,
49 doesVideoFileOfVideoExist
50} from '../../../helpers/middlewares'
46import { MVideoFullLight } from '@server/typings/models' 51import { MVideoFullLight } from '@server/typings/models'
47import { getVideoWithAttributes } from '../../../helpers/video' 52import { getVideoWithAttributes } from '../../../helpers/video'
48 53
@@ -198,6 +203,20 @@ const videosCustomGetValidator = (
198const videosGetValidator = videosCustomGetValidator('all') 203const videosGetValidator = videosCustomGetValidator('all')
199const videosDownloadValidator = videosCustomGetValidator('all', true) 204const videosDownloadValidator = videosCustomGetValidator('all', true)
200 205
206const 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
201const videosRemoveValidator = [ 220const 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'
3import { Col } from 'sequelize/types/lib/utils' 3import { Col } from 'sequelize/types/lib/utils'
4import { literal, OrderItem } from 'sequelize' 4import { literal, OrderItem } from 'sequelize'
5 5
6type Primitive = string | Function | number | boolean | Symbol | undefined | null
7type 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}
17type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>>
18
19type DeepOmitArray<T extends any[], K> = {
20 [P in keyof T]: DeepOmit<T[P], K>
21}
22
6type SortType = { sortModel: string, sortValue: string } 23type 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
195export { 212export {
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'
15import { 17import {
16 isVideoFileExtnameValid, 18 isVideoFileExtnameValid,
@@ -29,6 +31,60 @@ import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '.
29import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' 31import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models'
30import * as memoizee from 'memoizee' 32import * as memoizee from 'memoizee'
31 33
34export enum ScopeNames {
35 WITH_VIDEO = 'WITH_VIDEO',
36 WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST',
37 WITH_METADATA = 'WITH_METADATA'
38}
39
40const 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 {
23import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' 23import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file'
24import { VideoFile } from '@shared/models/videos/video-file.model' 24import { VideoFile } from '@shared/models/videos/video-file.model'
25import { generateMagnetUri } from '@server/helpers/webtorrent' 25import { generateMagnetUri } from '@server/helpers/webtorrent'
26import { extractVideo } from '@server/lib/videos'
26 27
27export type VideoFormattingJSONOptions = { 28export 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'
4import 'mocha' 4import 'mocha'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import { getMaxBitrate, VideoDetails, VideoResolution, VideoState } from '../../../../shared/models/videos' 6import { getMaxBitrate, VideoDetails, VideoResolution, VideoState } from '../../../../shared/models/videos'
7import { audio, canDoQuickTranscode, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' 7import {
8 audio,
9 canDoQuickTranscode,
10 getVideoFileBitrate,
11 getVideoFileFPS,
12 getVideoFileResolution,
13 getMetadataFromFile
14} from '../../../helpers/ffmpeg-utils'
8import { 15import {
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'
26import { join } from 'path' 34import { join } from 'path'
27import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants' 35import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants'
36import { FfprobeData } from 'fluent-ffmpeg'
28 37
29const expect = chai.expect 38const 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 })
diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts
index 39a06b0d7..0d36a38a2 100644
--- a/shared/extra-utils/videos/videos.ts
+++ b/shared/extra-utils/videos/videos.ts
@@ -95,6 +95,14 @@ function getVideo (url: string, id: number | string, expectedStatus = 200) {
95 .expect(expectedStatus) 95 .expect(expectedStatus)
96} 96}
97 97
98function getVideoFileMetadataUrl (url: string) {
99 return request(url)
100 .get('/')
101 .set('Accept', 'application/json')
102 .expect(200)
103 .expect('Content-Type', /json/)
104}
105
98function viewVideo (url: string, id: number | string, expectedStatus = 204, xForwardedFor?: string) { 106function viewVideo (url: string, id: number | string, expectedStatus = 204, xForwardedFor?: string) {
99 const path = '/api/v1/videos/' + id + '/views' 107 const path = '/api/v1/videos/' + id + '/views'
100 108
@@ -643,6 +651,7 @@ export {
643 getAccountVideos, 651 getAccountVideos,
644 getVideoChannelVideos, 652 getVideoChannelVideos,
645 getVideo, 653 getVideo,
654 getVideoFileMetadataUrl,
646 getVideoWithToken, 655 getVideoWithToken,
647 getVideosList, 656 getVideosList,
648 getVideosListPagination, 657 getVideosListPagination,
diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts
index e94d05429..bb3ffe678 100644
--- a/shared/models/activitypub/objects/common-objects.ts
+++ b/shared/models/activitypub/objects/common-objects.ts
@@ -28,6 +28,15 @@ export type ActivityPlaylistSegmentHashesObject = {
28 href: string 28 href: string
29} 29}
30 30
31export type ActivityVideoFileMetadataObject = {
32 type: 'Link'
33 rel: [ 'metadata', any ]
34 mediaType: 'application/json'
35 height: number
36 href: string
37 fps: number
38}
39
31export type ActivityPlaylistInfohashesObject = { 40export type ActivityPlaylistInfohashesObject = {
32 type: 'Infohash' 41 type: 'Infohash'
33 name: string 42 name: string
@@ -80,6 +89,7 @@ export type ActivityTagObject =
80 | ActivityMentionObject 89 | ActivityMentionObject
81 | ActivityBitTorrentUrlObject 90 | ActivityBitTorrentUrlObject
82 | ActivityMagnetUrlObject 91 | ActivityMagnetUrlObject
92 | ActivityVideoFileMetadataObject
83 93
84export type ActivityUrlObject = 94export type ActivityUrlObject =
85 ActivityVideoUrlObject 95 ActivityVideoUrlObject
@@ -87,6 +97,7 @@ export type ActivityUrlObject =
87 | ActivityBitTorrentUrlObject 97 | ActivityBitTorrentUrlObject
88 | ActivityMagnetUrlObject 98 | ActivityMagnetUrlObject
89 | ActivityHtmlUrlObject 99 | ActivityHtmlUrlObject
100 | ActivityVideoFileMetadataObject
90 101
91export interface ActivityPubAttributedTo { 102export interface ActivityPubAttributedTo {
92 type: 'Group' | 'Person' 103 type: 'Group' | 'Person'
diff --git a/shared/models/videos/video-file-metadata.ts b/shared/models/videos/video-file-metadata.ts
new file mode 100644
index 000000000..15683cacf
--- /dev/null
+++ b/shared/models/videos/video-file-metadata.ts
@@ -0,0 +1,18 @@
1import { FfprobeData } from "fluent-ffmpeg"
2import { DeepOmit } from "@server/models/utils"
3
4export type VideoFileMetadataModel = DeepOmit<FfprobeData, 'filename'>
5
6export class VideoFileMetadata implements VideoFileMetadataModel {
7 streams: { [x: string]: any, [x: number]: any }[]
8 format: { [x: string]: any, [x: number]: any }
9 chapters: any[]
10
11 constructor (hash: Partial<VideoFileMetadataModel>) {
12 this.chapters = hash.chapters
13 this.format = hash.format
14 this.streams = hash.streams
15
16 delete this.format.filename
17 }
18}
diff --git a/shared/models/videos/video-file.model.ts b/shared/models/videos/video-file.model.ts
index 04da0627e..6cc2d5aee 100644
--- a/shared/models/videos/video-file.model.ts
+++ b/shared/models/videos/video-file.model.ts
@@ -1,4 +1,5 @@
1import { VideoConstant, VideoResolution } from '@shared/models' 1import { VideoConstant, VideoResolution } from '@shared/models'
2import { FfprobeData } from 'fluent-ffmpeg'
2 3
3export interface VideoFile { 4export interface VideoFile {
4 magnetUri: string 5 magnetUri: string
@@ -9,4 +10,6 @@ export interface VideoFile {
9 fileUrl: string 10 fileUrl: string
10 fileDownloadUrl: string 11 fileDownloadUrl: string
11 fps: number 12 fps: number
13 metadata?: FfprobeData
14 metadataUrl?: string
12} 15}