From 8319d6ae72d4da6de51bd3d4b5c68040fc8dc3b4 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Tue, 10 Mar 2020 14:39:40 +0100 Subject: 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 --- .../video/modals/video-download.component.html | 38 ++++++- .../video/modals/video-download.component.scss | 35 +++++++ .../video/modals/video-download.component.ts | 109 ++++++++++++++++++++- client/src/app/shared/video/video.service.ts | 9 ++ client/src/sass/bootstrap.scss | 7 +- 5 files changed, 192 insertions(+), 6 deletions(-) (limited to 'client') 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 @@
- @@ -38,6 +38,42 @@
+ + + Format + + + + + + Video stream + + + + + + Audio stream + + + + + +
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 @@ margin-right: 30px; } } + +.file-metadata { + padding: 1rem; +} + +.file-metadata .metadata-attribute { + font-size: 13px; + display: block; + margin-bottom: 12px; + + .metadata-attribute-label { + min-width: 142px; + padding-right: 5px; + display: inline-block; + color: $grey-foreground-color; + font-weight: $font-bold; + } + + a.metadata-attribute-value { + @include disable-default-a-behaviour; + color: var(--mainForegroundColor); + + &:hover { + opacity: 0.9; + } + } + + &.metadata-attribute-tags { + .metadata-attribute-value:not(:nth-child(2)) { + &::before { + content: ', ' + } + } + } +} 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' import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { I18n } from '@ngx-translate/i18n-polyfill' import { AuthService, Notifier } from '@app/core' -import { VideoPrivacy, VideoCaption } from '@shared/models' +import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models' +import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' +import { mapValues, pick } from 'lodash-es' +import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' +import { BytesPipe } from 'ngx-pipes' +import { VideoService } from '../video.service' type DownloadType = 'video' | 'subtitles' +type FileMetadata = { [key: string]: { label: string, value: string }} @Component({ selector: 'my-video-download', @@ -20,17 +26,28 @@ export class VideoDownloadComponent { subtitleLanguageId: string video: VideoDetails + videoFile: VideoFile + videoFileMetadataFormat: FileMetadata + videoFileMetadataVideoStream: FileMetadata | undefined + videoFileMetadataAudioStream: FileMetadata | undefined videoCaptions: VideoCaption[] activeModal: NgbActiveModal type: DownloadType = 'video' + private bytesPipe: BytesPipe + private numbersPipe: NumberFormatterPipe + constructor ( private notifier: Notifier, private modalService: NgbModal, + private videoService: VideoService, private auth: AuthService, private i18n: I18n - ) { } + ) { + this.bytesPipe = new BytesPipe() + this.numbersPipe = new NumberFormatterPipe() + } get typeText () { return this.type === 'video' @@ -51,6 +68,7 @@ export class VideoDownloadComponent { this.activeModal = this.modalService.open(this.modal, { centered: true }) this.resolutionId = this.getVideoFiles()[0].resolution.id + this.onResolutionIdChange() if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id } @@ -67,10 +85,27 @@ export class VideoDownloadComponent { getLink () { return this.type === 'subtitles' && this.videoCaptions ? this.getSubtitlesLink() - : this.getVideoLink() + : this.getVideoFileLink() } - getVideoLink () { + async onResolutionIdChange () { + this.videoFile = this.getVideoFile() + if (this.videoFile.metadata || !this.videoFile.metadataUrl) return + + await this.hydrateMetadataFromMetadataUrl(this.videoFile) + + this.videoFileMetadataFormat = this.videoFile + ? this.getMetadataFormat(this.videoFile.metadata.format) + : undefined + this.videoFileMetadataVideoStream = this.videoFile + ? this.getMetadataStream(this.videoFile.metadata.streams, 'video') + : undefined + this.videoFileMetadataAudioStream = this.videoFile + ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio') + : undefined + } + + getVideoFile () { // HTML select send us a string, so convert it to a number this.resolutionId = parseInt(this.resolutionId.toString(), 10) @@ -79,6 +114,12 @@ export class VideoDownloadComponent { console.error('Could not find file with resolution %d.', this.resolutionId) return } + return file + } + + getVideoFileLink () { + const file = this.videoFile + if (!file) return const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL ? '?access_token=' + this.auth.getAccessToken() @@ -104,4 +145,64 @@ export class VideoDownloadComponent { switchToType (type: DownloadType) { this.type = type } + + getMetadataFormat (format: FfprobeFormat) { + const keyToTranslateFunction = { + 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }), + 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }), + 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }), + 'bit_rate': (value: number) => ({ + label: this.i18n('Bitrate'), + value: `${this.numbersPipe.transform(value)}bps` + }) + } + + // flattening format + const sanitizedFormat = Object.assign(format, format.tags) + delete sanitizedFormat.tags + + return mapValues( + pick(sanitizedFormat, Object.keys(keyToTranslateFunction)), + (val, key) => keyToTranslateFunction[key](val) + ) + } + + getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') { + const stream = streams.find(s => s.codec_type === type) + if (!stream) return undefined + + let keyToTranslateFunction = { + 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }), + 'profile': (value: string) => ({ label: this.i18n('Profile'), value }), + 'bit_rate': (value: number) => ({ + label: this.i18n('Bitrate'), + value: `${this.numbersPipe.transform(value)}bps` + }) + } + + if (type === 'video') { + keyToTranslateFunction = Object.assign(keyToTranslateFunction, { + 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }), + 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }), + 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }), + 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value }) + }) + } else { + keyToTranslateFunction = Object.assign(keyToTranslateFunction, { + 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }), + 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value }) + }) + } + + return mapValues( + pick(stream, Object.keys(keyToTranslateFunction)), + (val, key) => keyToTranslateFunction[key](val) + ) + } + + private hydrateMetadataFromMetadataUrl (file: VideoFile) { + const observable = this.videoService.getVideoFileMetadata(file.metadataUrl) + observable.subscribe(res => file.metadata = res) + return observable.toPromise() + } } 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 import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { I18n } from '@ngx-translate/i18n-polyfill' import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' +import { FfprobeData } from 'fluent-ffmpeg' export interface VideosProvider { getVideos (parameters: { @@ -291,6 +292,14 @@ export class VideoService implements VideosProvider { return this.buildBaseFeedUrls(params) } + getVideoFileMetadata (metadataUrl: string) { + return this.authHttp + .get(metadataUrl) + .pipe( + catchError(err => this.restExtractor.handleError(err)) + ) + } + removeVideo (id: number) { return this.authHttp .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/'; margin: 0; padding: 0; opacity: .5; + + &[iconName="cross"] { + @include icon(16px); + top: -3px; + } } } @@ -153,7 +158,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/'; } } -ngb-tabset.bootstrap { +ngb-tabset { .nav-link { &, & a { -- cgit v1.2.3