diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-03-10 14:39:40 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-03-10 14:39:40 +0100 |
commit | 8319d6ae72d4da6de51bd3d4b5c68040fc8dc3b4 (patch) | |
tree | 1f87041b2cd76222844960602cdc9f52fe206c7b | |
parent | edb868655e52f934a71141175cf9dc6cb4753e11 (diff) | |
download | PeerTube-8319d6ae72d4da6de51bd3d4b5c68040fc8dc3b4.tar.gz PeerTube-8319d6ae72d4da6de51bd3d4b5c68040fc8dc3b4.tar.zst PeerTube-8319d6ae72d4da6de51bd3d4b5c68040fc8dc3b4.zip |
Add video file metadata to download modal, via ffprobe (#2411)
* Add video file metadata via ffprobe
* Federate video file metadata
* Add tests for file metadata generation
* Complete tests for videoFile metadata federation
* Lint migration and video-file for metadata
* Objectify metadata from getter in ffmpeg-utils
* Add metadataUrl to all videoFiles
* Simplify metadata API middleware
* Load playlist in videoFile when requesting metadata
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' | |||
3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
5 | import { AuthService, Notifier } from '@app/core' | 5 | import { AuthService, Notifier } from '@app/core' |
6 | import { VideoPrivacy, VideoCaption } from '@shared/models' | 6 | import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models' |
7 | import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' | ||
8 | import { mapValues, pick } from 'lodash-es' | ||
9 | import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | ||
10 | import { BytesPipe } from 'ngx-pipes' | ||
11 | import { VideoService } from '../video.service' | ||
7 | 12 | ||
8 | type DownloadType = 'video' | 'subtitles' | 13 | type DownloadType = 'video' | 'subtitles' |
14 | type 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 | |||
32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
33 | import { I18n } from '@ngx-translate/i18n-polyfill' | 33 | import { I18n } from '@ngx-translate/i18n-polyfill' |
34 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | 34 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' |
35 | import { FfprobeData } from 'fluent-ffmpeg' | ||
35 | 36 | ||
36 | export interface VideosProvider { | 37 | export 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 | ||
156 | ngb-tabset.bootstrap { | 161 | ngb-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 @@ | |||
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 | }) |
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 | ||
98 | function getVideoFileMetadataUrl (url: string) { | ||
99 | return request(url) | ||
100 | .get('/') | ||
101 | .set('Accept', 'application/json') | ||
102 | .expect(200) | ||
103 | .expect('Content-Type', /json/) | ||
104 | } | ||
105 | |||
98 | function viewVideo (url: string, id: number | string, expectedStatus = 204, xForwardedFor?: string) { | 106 | function 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 | ||
31 | export type ActivityVideoFileMetadataObject = { | ||
32 | type: 'Link' | ||
33 | rel: [ 'metadata', any ] | ||
34 | mediaType: 'application/json' | ||
35 | height: number | ||
36 | href: string | ||
37 | fps: number | ||
38 | } | ||
39 | |||
31 | export type ActivityPlaylistInfohashesObject = { | 40 | export 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 | ||
84 | export type ActivityUrlObject = | 94 | export 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 | ||
91 | export interface ActivityPubAttributedTo { | 102 | export 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 @@ | |||
1 | import { FfprobeData } from "fluent-ffmpeg" | ||
2 | import { DeepOmit } from "@server/models/utils" | ||
3 | |||
4 | export type VideoFileMetadataModel = DeepOmit<FfprobeData, 'filename'> | ||
5 | |||
6 | export 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 @@ | |||
1 | import { VideoConstant, VideoResolution } from '@shared/models' | 1 | import { VideoConstant, VideoResolution } from '@shared/models' |
2 | import { FfprobeData } from 'fluent-ffmpeg' | ||
2 | 3 | ||
3 | export interface VideoFile { | 4 | export 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 | } |