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 /client/src | |
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
Diffstat (limited to 'client/src')
5 files changed, 192 insertions, 6 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 { |