diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6cf13e226..f3200d29e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -193,7 +193,7 @@ npm run dev ### Embed The embed is a standalone application built using Vite. -The generated files (HTML entrypoint and multiple JS and CSS files) are served by the PeerTube server (behind `localhost:9000/videos/embed/:videoUUID` or `localhost:9000/video-playlists/embed/:playlistUUID`). +The generated files (HTML entrypoint and multiple JS and CSS files) are served by the Vite server (behind `localhost:5173/videos/embed/:videoUUID` or `localhost:5173/video-playlists/embed/:playlistUUID`). The following command will compile embed files and run the PeerTube server: ``` diff --git a/apps/peertube-runner/src/server/process/shared/common.ts b/apps/peertube-runner/src/server/process/shared/common.ts index cf7682991..9569c6cd5 100644 --- a/apps/peertube-runner/src/server/process/shared/common.ts +++ b/apps/peertube-runner/src/server/process/shared/common.ts @@ -1,9 +1,10 @@ -import { remove } from 'fs-extra/esm' -import { join } from 'path' +import { pick } from '@peertube/peertube-core-utils' import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg' import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models' import { buildUUID } from '@peertube/peertube-node-utils' import { PeerTubeServer } from '@peertube/peertube-server-commands' +import { remove } from 'fs-extra/esm' +import { join } from 'path' import { ConfigManager, downloadFile, logger } from '../../../shared/index.js' import { getWinstonLogger } from './winston-logger.js' @@ -35,6 +36,18 @@ export async function downloadInputFile (options: { return destination } +export async function downloadSeparatedAudioFileIfNeeded (options: { + urls: string[] + job: JobWithToken + runnerToken: string +}) { + const { urls } = options + + if (!urls || urls.length === 0) return undefined + + return downloadInputFile({ url: urls[0], ...pick(options, [ 'job', 'runnerToken' ]) }) +} + export function scheduleTranscodingProgress (options: { server: PeerTubeServer runnerToken: string diff --git a/apps/peertube-runner/src/server/process/shared/process-live.ts b/apps/peertube-runner/src/server/process/shared/process-live.ts index ffb3c0c5a..d372d493d 100644 --- a/apps/peertube-runner/src/server/process/shared/process-live.ts +++ b/apps/peertube-runner/src/server/process/shared/process-live.ts @@ -1,9 +1,11 @@ -import { FSWatcher, watch } from 'chokidar' -import { FfmpegCommand } from 'fluent-ffmpeg' -import { ensureDir, remove } from 'fs-extra/esm' -import { basename, join } from 'path' import { wait } from '@peertube/peertube-core-utils' -import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@peertube/peertube-ffmpeg' +import { + ffprobePromise, + getVideoStreamBitrate, + getVideoStreamDimensionsInfo, + hasAudioStream, + hasVideoStream +} from '@peertube/peertube-ffmpeg' import { LiveRTMPHLSTranscodingSuccess, LiveRTMPHLSTranscodingUpdatePayload, @@ -12,6 +14,10 @@ import { ServerErrorCode } from '@peertube/peertube-models' import { buildUUID } from '@peertube/peertube-node-utils' +import { FSWatcher, watch } from 'chokidar' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { ensureDir, remove } from 'fs-extra/esm' +import { basename, join } from 'path' import { ConfigManager } from '../../../shared/config-manager.js' import { logger } from '../../../shared/index.js' import { buildFFmpegLive, ProcessOptions } from './common.js' @@ -51,6 +57,7 @@ export class ProcessLiveRTMPHLSTranscoding { logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`) const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe) + const hasVideo = await hasVideoStream(payload.input.rtmpUrl, probe) const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe) const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe) @@ -103,11 +110,13 @@ export class ProcessLiveRTMPHLSTranscoding { segmentDuration: payload.output.segmentDuration, toTranscode: payload.output.toTranscode, + splitAudioAndVideo: true, bitrate, ratio, hasAudio, + hasVideo, probe }) diff --git a/apps/peertube-runner/src/server/process/shared/process-studio.ts b/apps/peertube-runner/src/server/process/shared/process-studio.ts index 11b7b7d9a..56acad140 100644 --- a/apps/peertube-runner/src/server/process/shared/process-studio.ts +++ b/apps/peertube-runner/src/server/process/shared/process-studio.ts @@ -1,5 +1,3 @@ -import { remove } from 'fs-extra/esm' -import { join } from 'path' import { pick } from '@peertube/peertube-core-utils' import { RunnerJobStudioTranscodingPayload, @@ -12,17 +10,30 @@ import { VideoStudioTranscodingSuccess } from '@peertube/peertube-models' import { buildUUID } from '@peertube/peertube-node-utils' +import { remove } from 'fs-extra/esm' +import { join } from 'path' import { ConfigManager } from '../../../shared/config-manager.js' import { logger } from '../../../shared/index.js' -import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common.js' +import { + buildFFmpegEdition, + downloadInputFile, + downloadSeparatedAudioFileIfNeeded, + JobWithToken, + ProcessOptions, + scheduleTranscodingProgress +} from './common.js' export async function processStudioTranscoding (options: ProcessOptions) { const { server, job, runnerToken } = options const payload = job.payload - let inputPath: string + let videoInputPath: string + let separatedAudioInputPath: string + + let tmpVideoInputFilePath: string + let tmpSeparatedAudioInputFilePath: string + let outputPath: string - let tmpInputFilePath: string let tasksProgress = 0 @@ -36,8 +47,11 @@ export async function processStudioTranscoding (options: ProcessOptions = { - inputPath: string + videoInputPath: string + separatedAudioInputPath: string + outputPath: string + task: T runnerToken: string job: JobWithToken @@ -107,15 +128,15 @@ async function processTask (options: TaskProcessorOptions) { } async function processAddIntroOutro (options: TaskProcessorOptions) { - const { inputPath, task, runnerToken, job } = options + const { videoInputPath, task, runnerToken, job } = options - logger.debug('Adding intro/outro to ' + inputPath) + logger.debug(`Adding intro/outro to ${videoInputPath}`) const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) try { await buildFFmpegEdition().addIntroOutro({ - ...pick(options, [ 'inputPath', 'outputPath' ]), + ...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]), introOutroPath, type: task.name === 'add-intro' @@ -128,12 +149,12 @@ async function processAddIntroOutro (options: TaskProcessorOptions) { - const { inputPath, task } = options + const { videoInputPath, task } = options - logger.debug(`Cutting ${inputPath}`) + logger.debug(`Cutting ${videoInputPath}`) return buildFFmpegEdition().cutVideo({ - ...pick(options, [ 'inputPath', 'outputPath' ]), + ...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]), start: task.options.start, end: task.options.end @@ -141,15 +162,15 @@ function processCut (options: TaskProcessorOptions) { } async function processAddWatermark (options: TaskProcessorOptions) { - const { inputPath, task, runnerToken, job } = options + const { videoInputPath, task, runnerToken, job } = options - logger.debug('Adding watermark to ' + inputPath) + logger.debug(`Adding watermark to ${videoInputPath}`) const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) try { await buildFFmpegEdition().addWatermark({ - ...pick(options, [ 'inputPath', 'outputPath' ]), + ...pick(options, [ 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]), watermarkPath, diff --git a/apps/peertube-runner/src/server/process/shared/process-vod.ts b/apps/peertube-runner/src/server/process/shared/process-vod.ts index fe1715ca9..9fe309012 100644 --- a/apps/peertube-runner/src/server/process/shared/process-vod.ts +++ b/apps/peertube-runner/src/server/process/shared/process-vod.ts @@ -1,5 +1,3 @@ -import { remove } from 'fs-extra/esm' -import { join } from 'path' import { RunnerJobVODAudioMergeTranscodingPayload, RunnerJobVODHLSTranscodingPayload, @@ -9,9 +7,17 @@ import { VODWebVideoTranscodingSuccess } from '@peertube/peertube-models' import { buildUUID } from '@peertube/peertube-node-utils' +import { remove } from 'fs-extra/esm' +import { join } from 'path' import { ConfigManager } from '../../../shared/config-manager.js' import { logger } from '../../../shared/index.js' -import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js' +import { + buildFFmpegVOD, + downloadInputFile, + downloadSeparatedAudioFileIfNeeded, + ProcessOptions, + scheduleTranscodingProgress +} from './common.js' export async function processWebVideoTranscoding (options: ProcessOptions) { const { server, job, runnerToken } = options @@ -19,7 +25,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions {}, resolution: payload.output.resolution, - fps: payload.output.fps + fps: payload.output.fps, + separatedAudio: payload.output.separatedAudio }) const successBody: VODHLSTranscodingSuccess = { @@ -126,7 +142,8 @@ export async function processHLSTranscoding (options: ProcessOptions.mp4 that keeps the original audio track, with no video` + // eslint-disable-next-line max-len + description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users` }, { id: '144p', @@ -53,14 +54,14 @@ export class EditConfigurationService { ] } - getLiveResolutions () { - return this.getVODResolutions().filter(r => r.id !== '0p') - } - isTranscodingEnabled (form: FormGroup) { return form.value['transcoding']['enabled'] === true } + isHLSEnabled (form: FormGroup) { + return form.value['transcoding']['hls']['enabled'] === true + } + isRemoteRunnerVODEnabled (form: FormGroup) { return form.value['transcoding']['remoteRunners']['enabled'] === true } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss index b47a54085..138067fa3 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss @@ -152,3 +152,8 @@ my-actor-banner-edit { max-width: $form-max-width; } +h4 { + font-weight: $font-bold; + margin-bottom: 0.5rem; + font-size: 1rem; +} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index a85b17165..65c233398 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -1,7 +1,6 @@ -import omit from 'lodash-es/omit' -import { forkJoin } from 'rxjs' -import { SelectOptionsItem } from 'src/types/select-options-item.model' +import { NgFor, NgIf } from '@angular/common' import { Component, OnInit } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { ActivatedRoute, Router } from '@angular/router' import { ConfigService } from '@app/+admin/config/shared/config.service' import { Notifier } from '@app/core' @@ -28,18 +27,19 @@ import { import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service' +import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap' import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models' -import { EditConfigurationService } from './edit-configuration.service' +import omit from 'lodash-es/omit' +import { forkJoin } from 'rxjs' +import { SelectOptionsItem } from 'src/types/select-options-item.model' import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component' +import { EditBasicConfigurationComponent } from './edit-basic-configuration.component' +import { EditConfigurationService } from './edit-configuration.service' +import { EditHomepageComponent } from './edit-homepage.component' +import { EditInstanceInformationComponent } from './edit-instance-information.component' import { EditLiveConfigurationComponent } from './edit-live-configuration.component' import { EditVODTranscodingComponent } from './edit-vod-transcoding.component' -import { EditBasicConfigurationComponent } from './edit-basic-configuration.component' -import { EditInstanceInformationComponent } from './edit-instance-information.component' -import { EditHomepageComponent } from './edit-homepage.component' -import { NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { NgIf, NgFor } from '@angular/common' -import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service' type ComponentCustomConfig = CustomConfig & { instanceCustomHomepage: CustomPage @@ -230,7 +230,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { keep: null }, hls: { - enabled: null + enabled: null, + splitAudioAndVideo: null }, webVideos: { enabled: null @@ -341,12 +342,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { } } - for (const resolution of this.editConfigurationService.getVODResolutions()) { + for (const resolution of this.editConfigurationService.getTranscodingResolutions()) { defaultValues.transcoding.resolutions[resolution.id] = 'false' formGroupData.transcoding.resolutions[resolution.id] = null - } - for (const resolution of this.editConfigurationService.getLiveResolutions()) { defaultValues.live.transcoding.resolutions[resolution.id] = 'false' formGroupData.live.transcoding.resolutions[resolution.id] = null } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html index 5120c763d..d6edd95f2 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html @@ -114,33 +114,36 @@

Output formats

-
- +
-
+
+

Live resolutions to generate

- -
+
+ + +
+ + +
+
+
+
+
+ +
- -
-
+ + Even if it's above your maximum enabled resolution +
- - -
- - - Even if it's above your maximum enabled resolution - -
@@ -148,7 +151,7 @@
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts index 76e5cc3d0..8e5e21323 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts @@ -56,7 +56,7 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges { { id: 1000 * 3600 * 10, label: $localize`10 hours` } ] - this.liveResolutions = this.editConfigurationService.getLiveResolutions() + this.liveResolutions = this.editConfigurationService.getTranscodingResolutions() } ngOnChanges (changes: SimpleChanges) { diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html index b82850858..413aaccf3 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html @@ -115,7 +115,25 @@

If you also enabled Web Videos support, it will multiply videos storage by 2

+ + + +
+ + + Store the audio stream in a separate file from the video.
+ This option adds the ability for the HLS player to propose the "Audio only" quality to users.
+ It also saves disk space by not duplicating the audio stream in each resolution file +
+
+
+ +
+
@@ -123,16 +141,6 @@
Resolutions to generate
- - - - - The original file resolution will be the default target if no option is selected. - -
+ + + + Even if it's above your maximum enabled resolution + +
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts index 8a99f9c6d..89493f9c5 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts @@ -1,15 +1,16 @@ -import { SelectOptionsItem } from 'src/types/select-options-item.model' +import { NgClass, NgFor, NgIf } from '@angular/common' import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { RouterLink } from '@angular/router' +import { Notifier } from '@app/core' import { HTMLServerConfig } from '@peertube/peertube-models' +import { SelectOptionsItem } from 'src/types/select-options-item.model' +import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' +import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' +import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' +import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive' import { ConfigService } from '../shared/config.service' import { EditConfigurationService, ResolutionOption } from './edit-configuration.service' -import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' -import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' -import { RouterLink } from '@angular/router' -import { NgClass, NgFor, NgIf } from '@angular/common' -import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive' -import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' @Component({ selector: 'my-edit-vod-transcoding', @@ -42,12 +43,13 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges { constructor ( private configService: ConfigService, - private editConfigurationService: EditConfigurationService + private editConfigurationService: EditConfigurationService, + private notifier: Notifier ) { } ngOnInit () { this.transcodingThreadOptions = this.configService.transcodingThreadOptions - this.resolutions = this.editConfigurationService.getVODResolutions() + this.resolutions = this.editConfigurationService.getTranscodingResolutions() this.checkTranscodingFields() } @@ -84,6 +86,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges { return this.editConfigurationService.isTranscodingEnabled(this.form) } + isHLSEnabled () { + return this.editConfigurationService.isHLSEnabled(this.form) + } + isStudioEnabled () { return this.editConfigurationService.isStudioEnabled(this.form) } @@ -92,6 +98,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges { return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() } } + getHLSDisabledClass () { + return { 'disabled-checkbox-extra': !this.isHLSEnabled() } + } + getLocalTranscodingDisabledClass () { return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() } } @@ -111,33 +121,31 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges { const webVideosControl = this.form.get('transcoding.webVideos.enabled') webVideosControl.valueChanges - .subscribe(newValue => { - if (newValue === false && !hlsControl.disabled) { - hlsControl.disable() - } + .subscribe(newValue => { + if (newValue === false && hlsControl.value === false) { + hlsControl.setValue(true) - if (newValue === true && !hlsControl.enabled) { - hlsControl.enable() - } - }) + // eslint-disable-next-line max-len + this.notifier.info($localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000) + } + }) hlsControl.valueChanges - .subscribe(newValue => { - if (newValue === false && !webVideosControl.disabled) { - webVideosControl.disable() - } + .subscribe(newValue => { + if (newValue === false && webVideosControl.value === false) { + webVideosControl.setValue(true) - if (newValue === true && !webVideosControl.enabled) { - webVideosControl.enable() - } - }) + // eslint-disable-next-line max-len + this.notifier.info($localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000) + } + }) transcodingControl.valueChanges - .subscribe(newValue => { - if (newValue === false) { - videoStudioControl.setValue(false) - } - }) + .subscribe(newValue => { + if (newValue === false) { + videoStudioControl.setValue(false) + } + }) transcodingControl.updateValueAndValidity() webVideosControl.updateValueAndValidity() diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts index 417380f35..681f814f6 100644 --- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts +++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts @@ -13,7 +13,7 @@ import { VideoRateComponent } from './video-rate.component' import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component' import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component' -import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/video-download.component' +import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/download/video-download.component' import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' @Component({ diff --git a/client/src/app/shared/shared-main/video/video-details.model.ts b/client/src/app/shared/shared-main/video/video-details.model.ts index f595414c0..2ec081fe9 100644 --- a/client/src/app/shared/shared-main/video/video-details.model.ts +++ b/client/src/app/shared/shared-main/video/video-details.model.ts @@ -65,13 +65,4 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { hasHlsPlaylist () { return !!this.getHlsPlaylist() } - - getFiles () { - if (this.files.length !== 0) return this.files - - const hls = this.getHlsPlaylist() - if (hls) return hls.files - - return [] - } } diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 1920bc46d..8438d4de5 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -16,6 +16,7 @@ import { VideoChannel as VideoChannelServerModel, VideoConstant, VideoDetails as VideoDetailsServerModel, + VideoFile, VideoFileMetadata, VideoIncludeType, VideoPrivacy, @@ -54,6 +55,7 @@ export type CommonVideoParams = { @Injectable() export class VideoService { + static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate' static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos' static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml' @@ -388,6 +390,22 @@ export class VideoService { // --------------------------------------------------------------------------- + generateDownloadUrl (options: { + video: Video + files: VideoFile[] + }) { + const { video, files } = options + + if (files.length === 0) throw new Error('Cannot generate download URL without files') + + let url = `${VideoService.BASE_VIDEO_DOWNLOAD_URL}/${video.uuid}?` + url += files.map(f => 'videoFileIds=' + f.id).join('&') + + return url + } + + // --------------------------------------------------------------------------- + getStoryboards (videoId: string | number, videoPassword: string) { const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) diff --git a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts index 7b84ae509..242b95063 100644 --- a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts +++ b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts @@ -179,17 +179,17 @@ export class UserSubscriptionService { } doesSubscriptionExist (nameWithHost: string) { - debugLogger('Running subscription check for %d.', nameWithHost) + debugLogger('Running subscription check for ' + nameWithHost) if (nameWithHost in this.myAccountSubscriptionCache) { - debugLogger('Found cache for %d.', nameWithHost) + debugLogger('Found cache for ' + nameWithHost) return of(this.myAccountSubscriptionCache[nameWithHost]) } this.existsSubject.next(nameWithHost) - debugLogger('Fetching from network for %d.', nameWithHost) + debugLogger('Fetching from network for ' + nameWithHost) return this.existsObservable.pipe( filter(existsResult => existsResult[nameWithHost] !== undefined), map(existsResult => existsResult[nameWithHost]), diff --git a/client/src/app/shared/shared-video-miniature/download/subtitle-files-download.component.html b/client/src/app/shared/shared-video-miniature/download/subtitle-files-download.component.html new file mode 100644 index 000000000..5f9a3d3e0 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/subtitle-files-download.component.html @@ -0,0 +1,24 @@ + + +
+ + diff --git a/client/src/app/shared/shared-video-miniature/download/subtitle-files-download.component.ts b/client/src/app/shared/shared-video-miniature/download/subtitle-files-download.component.ts new file mode 100644 index 000000000..65d29590e --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/subtitle-files-download.component.ts @@ -0,0 +1,71 @@ +import { NgFor, NgIf } from '@angular/common' +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap' +import { VideoCaption } from '@peertube/peertube-models' +import { logger } from '@root-helpers/logger' +import { InputTextComponent } from '../../shared-forms/input-text.component' + +@Component({ + selector: 'my-subtitle-files-download', + templateUrl: './subtitle-files-download.component.html', + standalone: true, + imports: [ + NgIf, + NgFor, + InputTextComponent, + NgbNav, + NgbNavItem, + NgbNavLink, + NgbNavLinkBase, + NgbNavContent, + NgbNavOutlet + ] +}) +export class SubtitleFilesDownloadComponent implements OnInit { + @Input({ required: true }) videoCaptions: VideoCaption[] + + @Output() downloaded = new EventEmitter() + + activeNavId: string + + getCaptions () { + if (!this.videoCaptions) return [] + + return this.videoCaptions + } + + ngOnInit () { + if (this.hasCaptions()) { + this.activeNavId = this.videoCaptions[0].language.id + } + } + + download () { + window.location.assign(this.getCaptionLink()) + + this.downloaded.emit() + } + + hasCaptions () { + return this.getCaptions().length !== 0 + } + + getCaption () { + const caption = this.getCaptions() + .find(c => c.language.id === this.activeNavId) + + if (!caption) { + logger.error(`Cannot find caption ${this.activeNavId}`) + return undefined + } + + return caption + } + + getCaptionLink () { + const caption = this.getCaption() + if (!caption) return '' + + return window.location.origin + caption.captionPath + } +} diff --git a/client/src/app/shared/shared-video-miniature/download/video-download.component.html b/client/src/app/shared/shared-video-miniature/download/video-download.component.html new file mode 100644 index 000000000..3997ed8e0 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-download.component.html @@ -0,0 +1,54 @@ + + + + + diff --git a/client/src/app/shared/shared-video-miniature/download/video-download.component.scss b/client/src/app/shared/shared-video-miniature/download/video-download.component.scss new file mode 100644 index 000000000..73b309028 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-download.component.scss @@ -0,0 +1,40 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.modal-body ::ng-deep { + + .nav-content { + margin-top: 30px; + } + + my-global-icon[iconName=shield] { + @include margin-left(10px); + + width: 16px; + position: relative; + top: -2px; + } + + .modal-footer { + padding-inline-end: 0; + margin-top: 1rem; + + > *:last-child { + margin-inline-end: 0; + } + } +} + +.peertube-select-container.title-select { + @include peertube-select-container(auto); + + display: inline-block; + margin-left: 10px; + vertical-align: top; +} + +#dropdown-download-type { + cursor: pointer; +} + + diff --git a/client/src/app/shared/shared-video-miniature/download/video-download.component.ts b/client/src/app/shared/shared-video-miniature/download/video-download.component.ts new file mode 100644 index 000000000..f59cfb602 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-download.component.ts @@ -0,0 +1,123 @@ +import { NgClass, NgIf, NgTemplateOutlet } from '@angular/common' +import { Component, ElementRef, Input, ViewChild } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { AuthService, HooksService } from '@app/core' +import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' +import { VideoCaption, VideoSource } from '@peertube/peertube-models' +import { videoRequiresFileToken } from '@root-helpers/video' +import { of } from 'rxjs' +import { catchError } from 'rxjs/operators' +import { VideoDetails } from '../../shared-main/video/video-details.model' +import { VideoFileTokenService } from '../../shared-main/video/video-file-token.service' +import { VideoService } from '../../shared-main/video/video.service' +import { SubtitleFilesDownloadComponent } from './subtitle-files-download.component' +import { VideoFilesDownloadComponent } from './video-files-download.component' +import { VideoGenerateDownloadComponent } from './video-generate-download.component' + +type DownloadType = 'video-generate' | 'video-files' | 'subtitle-files' + +@Component({ + selector: 'my-video-download', + templateUrl: './video-download.component.html', + styleUrls: [ './video-download.component.scss' ], + standalone: true, + imports: [ + SubtitleFilesDownloadComponent, + VideoFilesDownloadComponent, + VideoGenerateDownloadComponent, + GlobalIconComponent, + NgIf, + FormsModule, + NgClass, + NgTemplateOutlet + ] +}) +export class VideoDownloadComponent { + @ViewChild('modal', { static: true }) modal: ElementRef + + @Input() videoPassword: string + + video: VideoDetails + type: DownloadType = 'video-generate' + + videoFileToken: string + originalVideoFile: VideoSource + + loaded = false + + private videoCaptions: VideoCaption[] + private activeModal: NgbModalRef + + constructor ( + private modalService: NgbModal, + private authService: AuthService, + private videoService: VideoService, + private videoFileTokenService: VideoFileTokenService, + private hooks: HooksService + ) {} + + getCaptions () { + if (!this.videoCaptions) return [] + + return this.videoCaptions + } + + show (video: VideoDetails, videoCaptions?: VideoCaption[]) { + this.loaded = false + + this.videoFileToken = undefined + this.originalVideoFile = undefined + + this.video = video + this.videoCaptions = videoCaptions + + this.activeModal = this.modalService.open(this.modal, { centered: true }) + + this.getOriginalVideoFileObs() + .subscribe(source => { + if (source?.fileDownloadUrl) { + this.originalVideoFile = source + } + + if (this.originalVideoFile || videoRequiresFileToken(this.video)) { + this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword }) + .subscribe(({ token }) => { + this.videoFileToken = token + + this.loaded = true + }) + } else { + this.loaded = true + } + }) + + this.activeModal.shown.subscribe(() => { + this.hooks.runAction('action:modal.video-download.shown', 'common') + }) + } + + private getOriginalVideoFileObs () { + if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined) + + const user = this.authService.getUser() + if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined) + + return this.videoService.getSource(this.video.id) + .pipe(catchError(err => { + console.error('Cannot get source file', err) + + return of(undefined) + })) + } + + // --------------------------------------------------------------------------- + + onDownloaded () { + this.activeModal.close() + } + + hasCaptions () { + return this.getCaptions().length !== 0 + } +} diff --git a/client/src/app/shared/shared-video-miniature/download/video-files-download.component.html b/client/src/app/shared/shared-video-miniature/download/video-files-download.component.html new file mode 100644 index 000000000..34986c820 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-files-download.component.html @@ -0,0 +1,123 @@ +
+ The following link contains a private token and should not be shared with anyone. +
+ + + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ + + + diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.scss b/client/src/app/shared/shared-video-miniature/download/video-files-download.component.scss similarity index 67% rename from client/src/app/shared/shared-video-miniature/video-download.component.scss rename to client/src/app/shared/shared-video-miniature/download/video-files-download.component.scss index 56d6dc5f6..297492e2f 100644 --- a/client/src/app/shared/shared-video-miniature/video-download.component.scss +++ b/client/src/app/shared/shared-video-miniature/download/video-files-download.component.scss @@ -1,17 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; -.nav-content { - margin-top: 30px; -} - -my-global-icon[iconName=shield] { - @include margin-left(10px); - - width: 16px; - margin-top: -3px; -} - .advanced-filters-button { display: flex; justify-content: center; @@ -25,28 +14,6 @@ my-global-icon[iconName=shield] { } } -.peertube-select-container.title-select { - @include peertube-select-container(auto); - - display: inline-block; - margin-left: 10px; - vertical-align: top; -} - -#dropdown-download-type { - cursor: pointer; -} - -.download-type { - margin-top: 20px; - - .peertube-radio-container { - @include margin-right(30px); - - display: inline-block; - } -} - .nav-metadata { margin-top: 20px; } @@ -69,3 +36,13 @@ my-global-icon[iconName=shield] { font-weight: $font-bold; } } + +.download-type { + margin-top: 20px; + + .peertube-radio-container { + @include margin-right(30px); + + display: inline-block; + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/download/video-files-download.component.ts similarity index 54% rename from client/src/app/shared/shared-video-miniature/video-download.component.ts rename to client/src/app/shared/shared-video-miniature/download/video-files-download.component.ts index dff8a1f46..dc6a3f11c 100644 --- a/client/src/app/shared/shared-video-miniature/video-download.component.ts +++ b/client/src/app/shared/shared-video-miniature/download/video-files-download.component.ts @@ -1,11 +1,8 @@ import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' -import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core' +import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core' import { FormsModule } from '@angular/forms' -import { AuthService, HooksService } from '@app/core' import { NgbCollapse, - NgbModal, - NgbModalRef, NgbNav, NgbNavContent, NgbNavItem, @@ -15,34 +12,32 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { objectKeysTyped, pick } from '@peertube/peertube-core-utils' -import { VideoCaption, VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models' +import { VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { videoRequiresFileToken } from '@root-helpers/video' import { mapValues } from 'lodash-es' -import { firstValueFrom, of } from 'rxjs' -import { catchError, tap } from 'rxjs/operators' -import { InputTextComponent } from '../shared-forms/input-text.component' -import { GlobalIconComponent } from '../shared-icons/global-icon.component' -import { BytesPipe } from '../shared-main/angular/bytes.pipe' -import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe' -import { VideoDetails } from '../shared-main/video/video-details.model' -import { VideoFileTokenService } from '../shared-main/video/video-file-token.service' -import { VideoService } from '../shared-main/video/video.service' +import { firstValueFrom } from 'rxjs' +import { tap } from 'rxjs/operators' +import { InputTextComponent } from '../../shared-forms/input-text.component' +import { GlobalIconComponent } from '../../shared-icons/global-icon.component' +import { BytesPipe } from '../../shared-main/angular/bytes.pipe' +import { NumberFormatterPipe } from '../../shared-main/angular/number-formatter.pipe' +import { VideoDetails } from '../../shared-main/video/video-details.model' +import { VideoService } from '../../shared-main/video/video.service' -type DownloadType = 'video' | 'subtitles' type FileMetadata = { [key: string]: { label: string, value: string | number } } @Component({ - selector: 'my-video-download', - templateUrl: './video-download.component.html', - styleUrls: [ './video-download.component.scss' ], + selector: 'my-video-files-download', + templateUrl: './video-files-download.component.html', + styleUrls: [ './video-files-download.component.scss' ], standalone: true, imports: [ NgIf, FormsModule, GlobalIconComponent, - NgbNav, NgFor, + NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, @@ -56,15 +51,16 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } } NgClass ] }) -export class VideoDownloadComponent { - @ViewChild('modal', { static: true }) modal: ElementRef +export class VideoFilesDownloadComponent implements OnInit { + @Input({ required: true }) video: VideoDetails + @Input() originalVideoFile: VideoSource + @Input() videoFileToken: string - @Input() videoPassword: string + @Output() downloaded = new EventEmitter() downloadType: 'direct' | 'torrent' = 'direct' - resolutionId: number | 'original' = -1 - subtitleLanguageId: string + activeResolutionId: number | 'original' = -1 videoFileMetadataFormat: FileMetadata videoFileMetadataVideoStream: FileMetadata | undefined @@ -72,133 +68,50 @@ export class VideoDownloadComponent { isAdvancedCustomizationCollapsed = true - type: DownloadType = 'video' - - videoFileToken: string - - originalVideoFile: VideoSource - - loaded = false - - private activeModal: NgbModalRef - private bytesPipe: BytesPipe private numbersPipe: NumberFormatterPipe - private video: VideoDetails - private videoCaptions: VideoCaption[] - constructor ( @Inject(LOCALE_ID) private localeId: string, - private modalService: NgbModal, - private authService: AuthService, - private videoService: VideoService, - private videoFileTokenService: VideoFileTokenService, - private hooks: HooksService + private videoService: VideoService ) { this.bytesPipe = new BytesPipe() this.numbersPipe = new NumberFormatterPipe(this.localeId) } - get typeText () { - return this.type === 'video' - ? $localize`video` - : $localize`subtitles` - } - - getVideoFiles () { - if (!this.video) return [] - - return this.video.getFiles() - } - - getCaptions () { - if (!this.videoCaptions) return [] - - return this.videoCaptions - } - - show (video: VideoDetails, videoCaptions?: VideoCaption[]) { - this.loaded = false - - this.videoFileToken = undefined - this.originalVideoFile = undefined - - this.video = video - this.videoCaptions = videoCaptions - - this.activeModal = this.modalService.open(this.modal, { centered: true }) + ngOnInit () { if (this.hasFiles()) { this.onResolutionIdChange(this.getVideoFiles()[0].resolution.id) } - - if (this.hasCaptions()) { - this.subtitleLanguageId = this.videoCaptions[0].language.id - } - - this.getOriginalVideoFileObs() - .subscribe(source => { - if (source?.fileDownloadUrl) { - this.originalVideoFile = source - } - - if (this.originalVideoFile || this.isConfidentialVideo()) { - this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword }) - .subscribe(({ token }) => { - this.videoFileToken = token - - this.loaded = true - }) - } else { - this.loaded = true - } - }) - - this.activeModal.shown.subscribe(() => { - this.hooks.runAction('action:modal.video-download.shown', 'common') - }) } - private getOriginalVideoFileObs () { - if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined) + getVideoFiles () { + if (!this.video) return [] + if (this.video.files.length !== 0) return this.video.files - const user = this.authService.getUser() - if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined) + const hls = this.video.getHlsPlaylist() + if (hls) return hls.files - return this.videoService.getSource(this.video.id) - .pipe(catchError(err => { - console.error('Cannot get source file', err) - - return of(undefined) - })) + return [] } // --------------------------------------------------------------------------- - onClose () { - this.video = undefined - this.videoCaptions = undefined - } - download () { - window.location.assign(this.getLink()) + window.location.assign(this.getVideoFileLink()) - this.activeModal.close() + this.downloaded.emit() } - getLink () { - return this.type === 'subtitles' && this.videoCaptions - ? this.getCaptionLink() - : this.getVideoFileLink() - } + // --------------------------------------------------------------------------- async onResolutionIdChange (resolutionId: number | 'original') { - this.resolutionId = resolutionId + this.activeResolutionId = resolutionId let metadata: VideoFileMetadata - if (this.resolutionId === 'original') { + if (this.activeResolutionId === 'original') { metadata = this.originalVideoFile.metadata } else { const videoFile = this.getVideoFile() @@ -218,22 +131,20 @@ export class VideoDownloadComponent { this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio') } - onSubtitleIdChange (subtitleId: string) { - this.subtitleLanguageId = subtitleId - } + // --------------------------------------------------------------------------- hasFiles () { return this.getVideoFiles().length !== 0 } getVideoFile () { - if (this.resolutionId === 'original') return undefined + if (this.activeResolutionId === 'original') return undefined const file = this.getVideoFiles() - .find(f => f.resolution.id === this.resolutionId) + .find(f => f.resolution.id === this.activeResolutionId) if (!file) { - logger.error(`Could not find file with resolution ${this.resolutionId}`) + logger.error(`Could not find file with resolution ${this.activeResolutionId}`) return undefined } @@ -241,11 +152,11 @@ export class VideoDownloadComponent { } getVideoFileLink () { - const suffix = this.resolutionId === 'original' || this.isConfidentialVideo() + const suffix = this.activeResolutionId === 'original' || this.isConfidentialVideo() ? '?videoFileToken=' + this.videoFileToken : '' - if (this.resolutionId === 'original') { + if (this.activeResolutionId === 'original') { return this.originalVideoFile.fileDownloadUrl + suffix } @@ -261,36 +172,13 @@ export class VideoDownloadComponent { } } - hasCaptions () { - return this.getCaptions().length !== 0 - } - - getCaption () { - const caption = this.getCaptions() - .find(c => c.language.id === this.subtitleLanguageId) - - if (!caption) { - logger.error(`Cannot find caption ${this.subtitleLanguageId}`) - return undefined - } - - return caption - } - - getCaptionLink () { - const caption = this.getCaption() - if (!caption) return '' - - return window.location.origin + caption.captionPath - } + // --------------------------------------------------------------------------- isConfidentialVideo () { - return this.resolutionId === 'original' || videoRequiresFileToken(this.video) + return this.activeResolutionId === 'original' || videoRequiresFileToken(this.video) } - switchToType (type: DownloadType) { - this.type = type - } + // --------------------------------------------------------------------------- hasMetadata () { return !!this.videoFileMetadataFormat diff --git a/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.html b/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.html new file mode 100644 index 000000000..fe8dcc12f --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.html @@ -0,0 +1,37 @@ +
+ +
+ + + +
+ + @for (file of videoFiles; track file.id) { +
+ + + +
+ } + +
+ +
+ +
+ + diff --git a/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.scss b/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.scss new file mode 100644 index 000000000..c41485110 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.scss @@ -0,0 +1,9 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.peertube-radio-container strong { + @include margin-right(0.5rem); + + display: inline-block; + min-width: 80px; +} diff --git a/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.ts b/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.ts new file mode 100644 index 000000000..f0443e529 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/download/video-generate-download.component.ts @@ -0,0 +1,130 @@ +import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component' +import { VideoService } from '@app/shared/shared-main/video/video.service' +import { + NgbTooltip +} from '@ng-bootstrap/ng-bootstrap' +import { maxBy } from '@peertube/peertube-core-utils' +import { VideoFile, VideoResolution, VideoSource } from '@peertube/peertube-models' +import { videoRequiresFileToken } from '@root-helpers/video' +import { GlobalIconComponent } from '../../shared-icons/global-icon.component' +import { BytesPipe } from '../../shared-main/angular/bytes.pipe' +import { VideoDetails } from '../../shared-main/video/video-details.model' + +@Component({ + selector: 'my-video-generate-download', + templateUrl: './video-generate-download.component.html', + styleUrls: [ './video-generate-download.component.scss' ], + standalone: true, + imports: [ + NgIf, + FormsModule, + GlobalIconComponent, + PeertubeCheckboxComponent, + NgFor, + KeyValuePipe, + NgbTooltip, + NgTemplateOutlet, + NgClass, + BytesPipe + ] +}) +export class VideoGenerateDownloadComponent implements OnInit { + @Input({ required: true }) video: VideoDetails + @Input() originalVideoFile: VideoSource + @Input() videoFileToken: string + + @Output() downloaded = new EventEmitter() + + includeAudio = true + videoFileChosen = '' + videoFiles: VideoFile[] + + constructor (private videoService: VideoService) { + } + + ngOnInit () { + this.videoFiles = this.buildVideoFiles() + if (this.videoFiles.length === 0) return + + this.videoFileChosen = 'file-' + maxBy(this.videoFiles, 'resolution').id + } + + getFileSize (file: VideoFile) { + if (file.hasAudio && file.hasVideo) return file.size + if (file.hasAudio) return file.size + + if (this.includeAudio) { + const audio = this.findAudioFileOnly() + + return file.size + (audio.size || 0) + } + + return file.size + } + + hasAudioSplitted () { + if (this.videoFileChosen === 'file-original') return false + + return this.findCurrentFile().hasAudio === false && + this.videoFiles.some(f => f.hasVideo === false && f.hasAudio === true) + } + + // --------------------------------------------------------------------------- + + download () { + window.location.assign(this.getVideoFileLink()) + + this.downloaded.emit() + } + + // --------------------------------------------------------------------------- + + getVideoFileLink () { + const suffix = this.videoFileChosen === 'file-original' || this.isConfidentialVideo() + ? '?videoFileToken=' + this.videoFileToken + : '' + + if (this.videoFileChosen === 'file-original') { + return this.originalVideoFile.fileDownloadUrl + suffix + } + + const file = this.findCurrentFile() + if (!file) return '' + + const files = [ file ] + + if (this.hasAudioSplitted() && this.includeAudio) { + files.push(this.findAudioFileOnly()) + } + + return this.videoService.generateDownloadUrl({ video: this.video, files }) + } + + // --------------------------------------------------------------------------- + + isConfidentialVideo () { + return this.videoFileChosen === 'file-original' || videoRequiresFileToken(this.video) + } + + // --------------------------------------------------------------------------- + + private buildVideoFiles () { + if (!this.video) return [] + + const hls = this.video.getHlsPlaylist() + if (hls) return hls.files + + return this.video.files + } + + private findCurrentFile () { + return this.videoFiles.find(f => this.videoFileChosen === 'file-' + f.id) + } + + private findAudioFileOnly () { + return this.videoFiles.find(f => f.resolution.id === VideoResolution.H_NOVIDEO) + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts index 337629cb5..e11adfbb8 100644 --- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts @@ -22,7 +22,7 @@ import { VideoBlockComponent } from '../shared-moderation/video-block.component' import { VideoBlockService } from '../shared-moderation/video-block.service' import { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.component' import { VideoAddToPlaylistComponent } from '../shared-video-playlist/video-add-to-playlist.component' -import { VideoDownloadComponent } from './video-download.component' +import { VideoDownloadComponent } from './download/video-download.component' export type VideoActionsDisplayType = { playlist?: boolean diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.html b/client/src/app/shared/shared-video-miniature/video-download.component.html deleted file mode 100644 index 618d22178..000000000 --- a/client/src/app/shared/shared-video-miniature/video-download.component.html +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - - diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts index 691044648..a93fff7fb 100644 --- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts @@ -1,19 +1,15 @@ // Thanks https://github.com/streamroot/videojs-hlsjs-plugin // We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file -import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js' -import videojs from 'video.js' import { logger } from '@root-helpers/logger' -import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types' +import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js' +import videojs from 'video.js' +import { HLSPluginOptions, HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types' type ErrorCounts = { [ type: string ]: number } -type Metadata = { - levels: Level[] -} - // --------------------------------------------------------------------------- // Source handler registration // --------------------------------------------------------------------------- @@ -126,10 +122,10 @@ export class Html5Hlsjs { private maxNetworkErrorRecovery = 5 private hls: Hlsjs - private hlsjsConfig: Partial = null + private hlsjsConfig: HLSPluginOptions = null private _duration: number = null - private metadata: Metadata = null + private metadata: ManifestParsedData = null private isLive: boolean = null private dvrDuration: number = null private edgeMargin: number = null @@ -139,6 +135,8 @@ export class Html5Hlsjs { error: null } + private audioMode = false + constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { this.vjs = vjs this.source = source @@ -206,50 +204,14 @@ export class Html5Hlsjs { return this.vjs.createTimeRanges() } - // See comment for `initialize` method. dispose () { this.videoElement.removeEventListener('play', this.handlers.play) this.videoElement.removeEventListener('error', this.handlers.error) - // FIXME: https://github.com/video-dev/hls.js/issues/4092 - const untypedHLS = this.hls as any - untypedHLS.log = untypedHLS.warn = () => { - // empty - } - this.hls.destroy() } - static addHook (type: string, callback: HookFn) { - Html5Hlsjs.hooks[type] = this.hooks[type] || [] - Html5Hlsjs.hooks[type].push(callback) - } - - static removeHook (type: string, callback: HookFn) { - if (Html5Hlsjs.hooks[type] === undefined) return false - - const index = Html5Hlsjs.hooks[type].indexOf(callback) - if (index === -1) return false - - Html5Hlsjs.hooks[type].splice(index, 1) - - return true - } - - static removeAllHooks () { - Html5Hlsjs.hooks = {} - } - - private _executeHooksFor (type: string) { - if (Html5Hlsjs.hooks[type] === undefined) { - return - } - - // ES3 and IE < 9 - for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) { - Html5Hlsjs.hooks[type][i](this.player, this.hls) - } - } + // --------------------------------------------------------------------------- private _getHumanErrorMsg (error: { message: string, code?: number }) { switch (error.code) { @@ -265,11 +227,14 @@ export class Html5Hlsjs { } this.hls.destroy() + logger.info('bubbling error up to VIDEOJS') + this.tech.error = () => ({ ...error, message: this._getHumanErrorMsg(error) }) + this.tech.trigger('error') } @@ -335,16 +300,18 @@ export class Html5Hlsjs { } } + // --------------------------------------------------------------------------- + private buildLevelLabel (level: Level) { if (this.player.srOptions_.levelLabelHandler) { - return this.player.srOptions_.levelLabelHandler(level as any) + return this.player.srOptions_.levelLabelHandler(level, this.player) } if (level.height) return level.height + 'p' if (level.width) return Math.round(level.width * 9 / 16) + 'p' if (level.bitrate) return (level.bitrate / 1000) + 'kbps' - return '0' + return this.player.localize('Audio only') } private _removeQuality (index: number) { @@ -367,50 +334,61 @@ export class Html5Hlsjs { label: this.buildLevelLabel(level), selected: level.id === this.hls.manualLevel, - selectCallback: () => { - this.hls.currentLevel = index - } + selectCallback: () => this.manuallySelectVideoLevel(index) }) }) + // Add a manually injected "Audio only" quality that will reloads hls.js + const videoResolutions = resolutions.filter(r => r.height !== 0) + if (videoResolutions.length !== 0 && this.getSeparateAudioTrack()) { + const audioTrackUrl = this.getSeparateAudioTrack() + + resolutions.push({ + id: -2, // -1 is for "Auto quality" + label: this.player.localize('Audio only'), + selected: false, + selectCallback: () => { + if (this.audioMode) return + this.audioMode = true + + this.updateToAudioOrVideo(audioTrackUrl) + } + }) + } + resolutions.push({ id: -1, label: this.player.localize('Auto'), selected: true, - selectCallback: () => this.hls.currentLevel = -1 + selectCallback: () => this.manuallySelectVideoLevel(-1) }) this.player.peertubeResolutions().add(resolutions) } + private manuallySelectVideoLevel (index: number) { + if (this.audioMode) { + this.audioMode = false + this.updateToAudioOrVideo(this.source.src, index) + return + } + + this.hls.currentLevel = index + } + private _startLoad () { this.hls.startLoad(-1) this.videoElement.removeEventListener('play', this.handlers.play) } - private _oneLevelObjClone (obj: { [ id: string ]: any }) { - const result: { [id: string]: any } = {} - const objKeys = Object.keys(obj) - for (let i = 0; i < objKeys.length; i++) { - result[objKeys[i]] = obj[objKeys[i]] - } - - return result - } - private _onMetaData (_event: any, data: ManifestParsedData) { // This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later this.metadata = data this._notifyVideoQualities() } - private _initHlsjs () { - const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions - const srOptions_ = this.player.srOptions_ - - const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig - // Hls.js will write to the reference thus change the object for later streams - this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {} + private initialize () { + this.buildBaseConfig() if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) { this.hlsjsConfig.autoStartLoad = false @@ -423,9 +401,10 @@ export class Html5Hlsjs { this.videoElement.addEventListener('play', this.handlers.play) } - this.hls = new Hlsjs(this.hlsjsConfig) + const loader = this.hlsjsConfig.loaderBuilder() + this.hls = new Hlsjs({ ...this.hlsjsConfig, loader }) - this._executeHooksFor('beforeinitialize') + this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() }) this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data)) this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data)) @@ -446,30 +425,83 @@ export class Html5Hlsjs { if (this.isLive) this.maxNetworkErrorRecovery = 30 }) + this.registerLevelEventSwitch() + this.hls.once(Hlsjs.Events.FRAG_LOADED, () => { // Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls` // Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata this.tech.trigger('loadedmetadata') }) - this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => { - const resolutionId = this.hls.autoLevelEnabled - ? -1 - : data.level - - const autoResolutionChosenId = this.hls.autoLevelEnabled - ? data.level - : -1 - - this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false }) - }) - this.hls.attachMedia(this.videoElement) - this.hls.loadSource(this.source.src) } - private initialize () { - this._initHlsjs() + private updateToAudioOrVideo (newSource: string, startLevel?: number) { + this.player.addClass('vjs-updating-resolution') + + const currentTime = this.player.currentTime() + + this.dispose() + + this.buildBaseConfig() + this.hlsjsConfig.autoStartLoad = true + this.player.autoplay('play') + + const loader = this.hlsjsConfig.loaderBuilder() + this.hls = new Hlsjs({ + ...this.hlsjsConfig, + loader, + startPosition: this.duration() === Infinity + ? undefined + : currentTime, + startLevel + }) + + this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() }) + + this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data)) + this.registerLevelEventSwitch() + + this.hls.attachMedia(this.videoElement) + this.hls.loadSource(newSource) + + this.player.one('canplay', () => { + this.player.removeClass('vjs-updating-resolution') + }) + } + + private registerLevelEventSwitch () { + this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => { + let resolutionId = data.level + let autoResolutionChosenId = -1 + + if (this.audioMode) { + resolutionId = -2 + } else if (this.hls.autoLevelEnabled) { + resolutionId = -1 + autoResolutionChosenId = data.level + } + + this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false }) + }) + } + + private buildBaseConfig () { + const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions + const srOptions_ = this.player.srOptions_ + + const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig + + // Hls.js will write to the reference thus change the object for later streams + this.hlsjsConfig = hlsjsConfigRef + ? { ...hlsjsConfigRef } + : {} + } + + private getSeparateAudioTrack () { + if (this.metadata.audioTracks.length === 0) return undefined + + return this.metadata.audioTracks[0].url } } diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts index 311fc97e6..8610e5f81 100644 --- a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts @@ -6,6 +6,9 @@ import Hlsjs from 'hls.js' import videojs from 'video.js' import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' import { SettingsButton } from '../settings/settings-menu-button' +import debug from 'debug' + +const debugLogger = debug('peertube:player:p2p-media-loader') const Plugin = videojs.getPlugin('plugin') class P2pMediaLoaderPlugin extends Plugin { @@ -56,19 +59,23 @@ class P2pMediaLoaderPlugin extends Plugin { return } - // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 - (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => { + player.on('hlsjs-initialized', (_: any, { hlsjs, engine }) => { + this.p2pEngine?.removeAllListeners() + this.p2pEngine?.destroy() + clearInterval(this.networkInfoInterval) + this.hlsjs = hlsjs + this.p2pEngine = engine + + debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs, engine }) + + player.ready(() => this.initializePlugin()) }) player.src({ type: options.type, src: options.src }) - - player.ready(() => { - this.initializePlugin() - }) } dispose () { @@ -76,9 +83,7 @@ class P2pMediaLoaderPlugin extends Plugin { this.p2pEngine?.destroy() this.hlsjs?.destroy() - this.options.segmentValidator?.destroy(); - - (videojs as any).Html5Hlsjs?.removeAllHooks() + this.options.segmentValidator?.destroy() clearInterval(this.networkInfoInterval) @@ -112,8 +117,6 @@ class P2pMediaLoaderPlugin extends Plugin { private initializePlugin () { initHlsJsPlayer(this.player, this.hlsjs) - this.p2pEngine = this.options.loader.getEngine() - this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => { if (navigator.onLine === false) return diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts index efd33e11e..0a3dbef72 100644 --- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts @@ -3,6 +3,9 @@ import { logger } from '@root-helpers/logger' import { wait } from '@root-helpers/utils' import { removeQueryParams } from '@peertube/peertube-core-utils' import { isSameOrigin } from '../common' +import debug from 'debug' + +const debugLogger = debug('peertube:player:segment-validator') type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } } @@ -67,6 +70,8 @@ export class SegmentValidator { throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) } + debugLogger(`Validating ${filename} range ${segment.range}`) + const calculatedSha = await this.sha256Hex(segment.data) if (calculatedSha !== hashShouldBe) { throw new Error( diff --git a/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts index 3cbfcd4b3..b4a3a4d6e 100644 --- a/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts +++ b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts @@ -4,7 +4,13 @@ import { LiveVideoLatencyMode } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage' import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' -import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types' +import { + HLSLoaderClass, + HLSPluginOptions, + P2PMediaLoaderPluginOptions, + PeerTubePlayerContructorOptions, + PeerTubePlayerLoadOptions +} from '../../types' import { getRtcConfig, isSameOrigin } from '../common' import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' @@ -47,7 +53,7 @@ export class HLSOptionsBuilder { 'filter:internal.player.p2p-media-loader.options.result', this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator }) ) - const loader = new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as P2PMediaLoader + const loaderBuilder = () => new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as HLSLoaderClass const p2pMediaLoader: P2PMediaLoaderPluginOptions = { requiresUserAuth: this.options.requiresUserAuth, @@ -58,19 +64,22 @@ export class HLSOptionsBuilder { redundancyUrlManager, type: 'application/x-mpegURL', src: this.options.hls.playlistUrl, - segmentValidator, - loader + segmentValidator } const hlsjs = { - hlsjsConfig: this.getHLSJSOptions(loader), + hlsjsConfig: this.getHLSJSOptions(loaderBuilder), - levelLabelHandler: (level: { height: number, width: number }) => { + levelLabelHandler: (level: { height: number, width: number }, player: videojs.VideoJsPlayer) => { const resolution = Math.min(level.height || 0, level.width || 0) const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution) // We don't have files for live videos - if (!file) return level.height + if (!file) { + if (resolution === 0) return player.localize('Audio only') + + return level.height + 'p' + } let label = file.resolution.label if (file.fps >= 50) label += file.fps @@ -185,7 +194,7 @@ export class HLSOptionsBuilder { // --------------------------------------------------------------------------- - private getHLSJSOptions (loader: P2PMediaLoader) { + private getHLSJSOptions (loaderBuilder: () => HLSLoaderClass): HLSPluginOptions { const specificLiveOrVODOptions = this.options.isLive ? this.getHLSLiveOptions() : this.getHLSVODOptions() @@ -194,7 +203,7 @@ export class HLSOptionsBuilder { capLevelToPlayerSize: true, autoStartLoad: false, - loader, + loaderBuilder, ...specificLiveOrVODOptions } diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts index 4d6701003..37260834a 100644 --- a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts +++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts @@ -56,7 +56,9 @@ class PeerTubeResolutionsPlugin extends Plugin { if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return - this.autoResolutionChosenId = autoResolutionChosenId + if (autoResolutionChosenId !== undefined) { + this.autoResolutionChosenId = autoResolutionChosenId + } for (const r of this.resolutions) { r.selected = r.id === id diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts index 32249511c..b338d0d36 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-button.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts @@ -42,7 +42,7 @@ class ResolutionMenuButton extends MenuButton { for (const r of resolutions) { const label = r.label === '0p' - ? this.player().localize('Audio-only') + ? this.player().localize('Audio only') : r.label const component = new ResolutionMenuItem( diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index cf34e7aba..671d2f569 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts @@ -1,8 +1,10 @@ -import { HlsConfig, Level } from 'hls.js' -import videojs from 'video.js' import { Engine } from '@peertube/p2p-media-loader-hlsjs' import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' +import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js' +import videojs from 'video.js' import { BezelsPlugin } from '../shared/bezels/bezels-plugin' +import { ContextMenuPlugin } from '../shared/context-menu' +import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin' import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin' @@ -10,6 +12,7 @@ import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin' import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' +import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' import { PeerTubePlugin } from '../shared/peertube/peertube-plugin' import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' @@ -18,9 +21,6 @@ import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' import { UpNextPlugin } from '../shared/upnext/upnext-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' import { PlayerMode } from './peertube-player-options' -import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' -import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin' -import { ContextMenuPlugin } from '../shared/context-menu' declare module 'video.js' { @@ -79,10 +79,10 @@ export interface VideoJSTechHLS extends videojs.Tech { export interface HlsjsConfigHandlerOptions { hlsjsConfig?: HlsConfig - levelLabelHandler?: (level: Level) => string + levelLabelHandler?: (level: Level, player: videojs.Player) => string } -type PeerTubeResolution = { +export type PeerTubeResolution = { id: number height?: number @@ -94,21 +94,21 @@ type PeerTubeResolution = { selectCallback: () => void } -type VideoJSCaption = { +export type VideoJSCaption = { label: string language: string src: string automaticallyGenerated: boolean } -type VideoJSStoryboard = { +export type VideoJSStoryboard = { url: string width: number height: number interval: number } -type PeerTubePluginOptions = { +export type PeerTubePluginOptions = { autoPlayerRatio: { cssRatioVariable: string cssPlayerPortraitModeVariable: string @@ -136,14 +136,14 @@ type PeerTubePluginOptions = { poster: () => string } -type MetricsPluginOptions = { +export type MetricsPluginOptions = { mode: () => PlayerMode metricsUrl: () => string metricsInterval: () => number videoUUID: () => string } -type ContextMenuPluginOptions = { +export type ContextMenuPluginOptions = { content: () => { icon?: string label: string @@ -151,23 +151,23 @@ type ContextMenuPluginOptions = { }[] } -type ContextMenuItemOptions = { +export type ContextMenuItemOptions = { listener: (e: videojs.EventTarget.Event) => void label: string } -type StoryboardOptions = { +export type StoryboardOptions = { url: string width: number height: number interval: number } -type ChaptersOptions = { +export type ChaptersOptions = { chapters: VideoChapter[] } -type PlaylistPluginOptions = { +export type PlaylistPluginOptions = { elements: VideoPlaylistElement[] playlist: VideoPlaylist @@ -177,7 +177,7 @@ type PlaylistPluginOptions = { onItemClicked: (element: VideoPlaylistElement) => void } -type UpNextPluginOptions = { +export type UpNextPluginOptions = { timeout: number next: () => void @@ -186,33 +186,40 @@ type UpNextPluginOptions = { isSuspended: () => boolean } -type ProgressBarMarkerComponentOptions = { +export type ProgressBarMarkerComponentOptions = { timecode: number } -type NextPreviousVideoButtonOptions = { +export type NextPreviousVideoButtonOptions = { type: 'next' | 'previous' handler?: () => void isDisplayed: () => boolean isDisabled: () => boolean } -type PeerTubeLinkButtonOptions = { +export type PeerTubeLinkButtonOptions = { isDisplayed: () => boolean shortUUID: () => string instanceName: string } -type TheaterButtonOptions = { +export type TheaterButtonOptions = { isDisplayed: () => boolean } -type WebVideoPluginOptions = { +export type WebVideoPluginOptions = { videoFiles: VideoFile[] videoFileToken: () => string } -type P2PMediaLoaderPluginOptions = { +export type HLSLoaderClass = { + new (confg: HlsConfig): Loader + + getEngine(): Engine +} +export type HLSPluginOptions = Partial HLSLoaderClass }> + +export type P2PMediaLoaderPluginOptions = { redundancyUrlManager: RedundancyUrlManager | null segmentValidator: SegmentValidator | null @@ -221,8 +228,6 @@ type P2PMediaLoaderPluginOptions = { p2pEnabled: boolean - loader: P2PMediaLoader - requiresUserAuth: boolean videoFileToken: () => string } @@ -233,7 +238,7 @@ export type P2PMediaLoader = { destroy: () => void } -type VideoJSPluginOptions = { +export type VideoJSPluginOptions = { playlist?: PlaylistPluginOptions peertube: PeerTubePluginOptions @@ -244,7 +249,7 @@ type VideoJSPluginOptions = { p2pMediaLoader?: P2PMediaLoaderPluginOptions } -type LoadedQualityData = { +export type LoadedQualityData = { qualitySwitchCallback: (resolutionId: number, type: 'video') => void qualityData: { video: { @@ -255,17 +260,17 @@ type LoadedQualityData = { } } -type ResolutionUpdateData = { +export type ResolutionUpdateData = { auto: boolean resolutionId: number id?: number } -type AutoResolutionUpdateData = { +export type AutoResolutionUpdateData = { possible: boolean } -type PlayerNetworkInfo = { +export type PlayerNetworkInfo = { source: 'web-video' | 'p2p-media-loader' http: { @@ -288,34 +293,8 @@ type PlayerNetworkInfo = { bandwidthEstimate?: number } -type PlaylistItemOptions = { +export type PlaylistItemOptions = { element: VideoPlaylistElement onClicked: () => void } - -export { - PlayerNetworkInfo, - TheaterButtonOptions, - VideoJSStoryboard, - PlaylistItemOptions, - NextPreviousVideoButtonOptions, - ResolutionUpdateData, - AutoResolutionUpdateData, - ProgressBarMarkerComponentOptions, - PlaylistPluginOptions, - MetricsPluginOptions, - VideoJSCaption, - PeerTubePluginOptions, - WebVideoPluginOptions, - P2PMediaLoaderPluginOptions, - ContextMenuItemOptions, - PeerTubeResolution, - VideoJSPluginOptions, - ContextMenuPluginOptions, - UpNextPluginOptions, - LoadedQualityData, - StoryboardOptions, - ChaptersOptions, - PeerTubeLinkButtonOptions -} diff --git a/client/src/standalone/videos/.env.development b/client/src/standalone/videos/.env.development new file mode 100644 index 000000000..e776fbe22 --- /dev/null +++ b/client/src/standalone/videos/.env.development @@ -0,0 +1 @@ +VITE_BACKEND_URL="http://localhost:9000" diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 186172692..2a8bb2edc 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -24,7 +24,8 @@ import { PlaylistFetcher, PlaylistTracker, Translations, - VideoFetcher + VideoFetcher, + getBackendUrl } from './shared' import { PlayerHTML } from './shared/player-html' @@ -58,7 +59,7 @@ export class PeerTubeEmbed { private requiresPassword: boolean constructor (videoWrapperId: string) { - logger.registerServerSending(window.location.origin) + logger.registerServerSending(getBackendUrl()) this.http = new AuthHTTP() @@ -73,7 +74,9 @@ export class PeerTubeEmbed { try { this.config = JSON.parse((window as any)['PeerTubeServerConfig']) } catch (err) { - logger.error('Cannot parse HTML config.', err) + if (!(import.meta as any).env.DEV) { + logger.error('Cannot parse HTML config.', err) + } } } @@ -90,12 +93,12 @@ export class PeerTubeEmbed { // --------------------------------------------------------------------------- async init () { - this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) + this.translationsPromise = TranslationsManager.getServerTranslations(getBackendUrl(), navigator.language) this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player') // Issue when we parsed config from HTML, fallback to API if (!this.config) { - this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false }) + this.config = await this.http.fetch(getBackendUrl() + '/api/v1/config', { optionalAuth: false }) .then(res => res.json()) } @@ -265,7 +268,7 @@ export class PeerTubeEmbed { // If already played, we are in a playlist so we don't want to display the poster between videos if (!this.alreadyPlayed) { - this.peertubePlayer.setPoster(window.location.origin + video.previewPath) + this.peertubePlayer.setPoster(getBackendUrl() + video.previewPath) } const playlist = this.playlistTracker @@ -351,6 +354,16 @@ export class PeerTubeEmbed { // --------------------------------------------------------------------------- private getResourceId () { + const search = window.location.search + + if (search.startsWith('?videoId=')) { + return search.replace(/^\?videoId=/, '') + } + + if (search.startsWith('?videoPlaylistId=')) { + return search.replace(/^\?videoPlaylistId=/, '') + } + const urlParts = window.location.pathname.split('/') return urlParts[urlParts.length - 1] } diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts index dcc522ac6..a09b8d450 100644 --- a/client/src/standalone/videos/shared/index.ts +++ b/client/src/standalone/videos/shared/index.ts @@ -5,5 +5,6 @@ export * from './player-html' export * from './player-options-builder' export * from './playlist-fetcher' export * from './playlist-tracker' +export * from './url' export * from './translations' export * from './video-fetcher' diff --git a/client/src/standalone/videos/shared/live-manager.ts b/client/src/standalone/videos/shared/live-manager.ts index 274f70d9c..a1fadd6d4 100644 --- a/client/src/standalone/videos/shared/live-manager.ts +++ b/client/src/standalone/videos/shared/live-manager.ts @@ -1,7 +1,8 @@ -import { Socket } from 'socket.io-client' import { LiveVideoEventPayload, VideoDetails, VideoState, VideoStateType } from '@peertube/peertube-models' +import { Socket } from 'socket.io-client' import { PlayerHTML } from './player-html' import { Translations } from './translations' +import { getBackendUrl } from './url' export class LiveManager { private liveSocket: Socket @@ -22,7 +23,7 @@ export class LiveManager { if (!this.liveSocket) { const io = (await import('socket.io-client')).io - this.liveSocket = io(window.location.origin + '/live-videos') + this.liveSocket = io(getBackendUrl() + '/live-videos') } const listener = (payload: LiveVideoEventPayload) => { diff --git a/client/src/standalone/videos/shared/peertube-plugin.ts b/client/src/standalone/videos/shared/peertube-plugin.ts index f9e668f1b..1643ebc9b 100644 --- a/client/src/standalone/videos/shared/peertube-plugin.ts +++ b/client/src/standalone/videos/shared/peertube-plugin.ts @@ -4,6 +4,7 @@ import { PluginInfo, PluginsManager } from '../../../root-helpers' import { RegisterClientHelpers } from '../../../types' import { AuthHTTP } from './auth-http' import { Translations } from './translations' +import { getBackendUrl } from './url' export class PeerTubePlugin { @@ -83,6 +84,6 @@ export class PeerTubePlugin { } private getPluginUrl () { - return window.location.origin + '/api/v1/plugins' + return getBackendUrl() + '/api/v1/plugins' } } diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts index a7672beb0..4436170d6 100644 --- a/client/src/standalone/videos/shared/player-options-builder.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts @@ -27,6 +27,7 @@ import { PlayerHTML } from './player-html' import { PlaylistTracker } from './playlist-tracker' import { Translations } from './translations' import { VideoFetcher } from './video-fetcher' +import { getBackendUrl } from './url' export class PlayerOptionsBuilder { private autoplay: boolean @@ -190,7 +191,7 @@ export class PlayerOptionsBuilder { videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous, metricsUrl: serverConfig.openTelemetry.metrics.enabled - ? window.location.origin + '/api/v1/metrics/playback' + ? getBackendUrl() + '/api/v1/metrics/playback' : null, metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval, @@ -204,7 +205,7 @@ export class PlayerOptionsBuilder { theaterButton: false, - serverUrl: window.location.origin, + serverUrl: getBackendUrl(), language: navigator.language, pluginsManager: this.peertubePlugin.getPluginsManager(), @@ -292,9 +293,9 @@ export class PlayerOptionsBuilder { duration: video.duration, videoRatio: video.aspectRatio, - poster: window.location.origin + video.previewPath, + poster: getBackendUrl() + video.previewPath, - embedUrl: window.location.origin + video.embedPath, + embedUrl: getBackendUrl() + video.embedPath, embedTitle: video.name, requiresUserAuth: videoRequiresUserAuth(video), @@ -333,7 +334,7 @@ export class PlayerOptionsBuilder { if (!storyboards || storyboards.length === 0) return undefined return { - url: window.location.origin + storyboards[0].storyboardPath, + url: getBackendUrl() + storyboards[0].storyboardPath, height: storyboards[0].spriteHeight, width: storyboards[0].spriteWidth, interval: storyboards[0].spriteDuration @@ -426,7 +427,7 @@ export class PlayerOptionsBuilder { label: peertubeTranslate(c.language.label, translations), language: c.language.id, automaticallyGenerated: c.automaticallyGenerated, - src: window.location.origin + c.captionPath + src: getBackendUrl() + c.captionPath })) } diff --git a/client/src/standalone/videos/shared/playlist-fetcher.ts b/client/src/standalone/videos/shared/playlist-fetcher.ts index db38e3d6c..9f7a9970d 100644 --- a/client/src/standalone/videos/shared/playlist-fetcher.ts +++ b/client/src/standalone/videos/shared/playlist-fetcher.ts @@ -1,6 +1,7 @@ import { HttpStatusCode, ResultList, VideoPlaylistElement } from '@peertube/peertube-models' import { logger } from '../../../root-helpers' import { AuthHTTP } from './auth-http' +import { getBackendUrl } from './url' export class PlaylistFetcher { @@ -68,6 +69,6 @@ export class PlaylistFetcher { } private getPlaylistUrl (id: string) { - return window.location.origin + '/api/v1/video-playlists/' + id + return getBackendUrl() + '/api/v1/video-playlists/' + id } } diff --git a/client/src/standalone/videos/shared/url.ts b/client/src/standalone/videos/shared/url.ts new file mode 100644 index 000000000..daf6cdc85 --- /dev/null +++ b/client/src/standalone/videos/shared/url.ts @@ -0,0 +1,3 @@ +export function getBackendUrl () { + return (import.meta as any).env.VITE_BACKEND_URL || window.location.origin +} diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts index c85d2bce4..ec2cc3380 100644 --- a/client/src/standalone/videos/shared/video-fetcher.ts +++ b/client/src/standalone/videos/shared/video-fetcher.ts @@ -2,6 +2,7 @@ import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '@peertube/p import { logger } from '../../../root-helpers' import { PeerTubeServerError } from '../../../types' import { AuthHTTP } from './auth-http' +import { getBackendUrl } from './url' export class VideoFetcher { @@ -70,11 +71,11 @@ export class VideoFetcher { } private getVideoUrl (id: string) { - return window.location.origin + '/api/v1/videos/' + id + return getBackendUrl() + '/api/v1/videos/' + id } private getLiveUrl (videoId: string) { - return window.location.origin + '/api/v1/videos/live/' + videoId + return getBackendUrl() + '/api/v1/videos/live/' + videoId } private loadStoryboards (videoUUID: string): Promise { @@ -82,7 +83,7 @@ export class VideoFetcher { } private getStoryboardsUrl (videoId: string) { - return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards' + return getBackendUrl() + '/api/v1/videos/' + videoId + '/storyboards' } private getVideoTokenUrl (id: string) { diff --git a/client/src/standalone/videos/vite.config.mjs b/client/src/standalone/videos/vite.config.mjs index 41494f3f4..7c69fd114 100644 --- a/client/src/standalone/videos/vite.config.mjs +++ b/client/src/standalone/videos/vite.config.mjs @@ -9,15 +9,40 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, '../../../') -export default defineConfig(() => { +export default defineConfig(({ mode }) => { return { - base: '/client/standalone/videos/', + base: mode === 'development' + ? '' + : '/client/standalone/videos/', + root: resolve(root, 'src', 'standalone', 'videos'), + server: { + proxy: { + '^/(videos|video-playlists)/(test-)?embed/[^\/\.]+$': { + target: 'http://localhost:5173', + rewrite: (path) => { + return path.replace('/videos/embed/', 'embed.html?videoId=') + .replace('/videos/test-embed/', 'test-embed.html?') + .replace('/video-playlists/embed/', 'embed.html?videoPlaylistId=') + .replace('/video-playlists/test-embed/', 'test-embed.html?videoPlaylistId=') + } + }, + '^/(videos|video-playlists)/(test-)?embed/.*': { + target: 'http://localhost:5173', + rewrite: (path) => { + return path.replace(/\/(videos|video-playlists)\/(test-)?embed\//, '') + } + }, + '^/lazy-static': { + target: 'http://localhost:9000' + } + } + }, + resolve: { alias: [ { find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') }, - { find: /^hls.js$/, replacement: resolve(root, './node_modules/hls.js/dist/hls.light.mjs') }, { find: '@root-helpers', replacement: resolve(root, './src/root-helpers') } ], }, @@ -33,6 +58,7 @@ export default defineConfig(() => { build: { outDir: resolve(root, 'dist', 'standalone', 'videos'), emptyOutDir: true, + sourcemap: mode === 'development', target: [ 'firefox78', 'ios12' ], diff --git a/client/tsconfig.json b/client/tsconfig.json index 64f671994..7df82ec1c 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -28,9 +28,6 @@ ], "baseUrl": "./", "paths": { - "hls.js": [ - "node_modules/hls.js/dist/hls.light" - ], "video.js": [ "node_modules/video.js/core" ], diff --git a/config/default.yaml b/config/default.yaml index 5187ff457..2948d0ea2 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -56,6 +56,11 @@ rates_limit: # 500 attempts in 10 seconds (to not break crawlers) window: 10 seconds max: 500 + download_generate_video: # A light FFmpeg process is used to generate videos (to merge audio and video streams for example) + # 5 attempts in 5 seconds + window: 5 seconds + max: 5 + oauth2: token_lifetime: @@ -588,7 +593,7 @@ transcoding: profile: 'default' resolutions: # Only created if the original video has a higher resolution, uses more storage! - 0p: false # audio-only (creates mp4 without video stream, always created when enabled) + 0p: false # audio-only (creates mp4 without video stream) 144p: false 240p: false 360p: false @@ -616,6 +621,11 @@ transcoding: hls: enabled: true + # Store the audio stream in a separate file from the video + # This option adds the ability for the HLS player to propose the "Audio only" quality to users + # It also saves disk space by not duplicating the audio stream in each resolution file + split_audio_and_video: false + live: enabled: false @@ -693,6 +703,7 @@ live: profile: 'default' resolutions: + 0p: false # Audio only 144p: false 240p: false 360p: false diff --git a/config/production.yaml.example b/config/production.yaml.example index dc9ff6749..783b09bb6 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -54,6 +54,11 @@ rates_limit: # 500 attempts in 10 seconds (to not break crawlers) window: 10 seconds max: 500 + download_generate_video: # A light FFmpeg process is used to generate videos (to merge audio and video streams for example) + # 5 attempts in 5 seconds + window: 5 seconds + max: 5 + oauth2: token_lifetime: @@ -598,7 +603,7 @@ transcoding: profile: 'default' resolutions: # Only created if the original video has a higher resolution, uses more storage! - 0p: false # audio-only (creates mp4 without video stream, always created when enabled) + 0p: false # audio-only (creates mp4 without video stream) 144p: false 240p: false 360p: false @@ -626,6 +631,11 @@ transcoding: hls: enabled: true + # Store the audio stream in a separate file from the video + # This option adds the ability for the HLS player to propose the "Audio only" quality to users + # It also saves disk space by not duplicating the audio stream in each resolution file + split_audio_and_video: false + live: enabled: false @@ -703,6 +713,7 @@ live: profile: 'default' resolutions: + 0p: false # Audio only 144p: false 240p: false 360p: false diff --git a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts index 2d1ab11db..321b333e3 100644 --- a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts +++ b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts @@ -1,4 +1,4 @@ -import { pick, promisify0 } from '@peertube/peertube-core-utils' +import { arrayify, pick, promisify0 } from '@peertube/peertube-core-utils' import { AvailableEncoders, EncoderOptionsBuilder, @@ -8,6 +8,7 @@ import { } from '@peertube/peertube-models' import { MutexInterface } from 'async-mutex' import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' +import { Readable } from 'node:stream' export interface FFmpegCommandWrapperOptions { availableEncoders?: AvailableEncoders @@ -83,15 +84,19 @@ export class FFmpegCommandWrapper { this.command = undefined } - buildCommand (input: string, inputFileMutexReleaser?: MutexInterface.Releaser) { + buildCommand (inputs: (string | Readable)[] | string | Readable, inputFileMutexReleaser?: MutexInterface.Releaser) { if (this.command) throw new Error('Command is already built') // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems - this.command = ffmpeg(input, { + this.command = ffmpeg({ niceness: this.niceness, cwd: this.tmpDirectory }) + for (const input of arrayify(inputs)) { + this.command.input(input) + } + if (this.threads > 0) { // If we don't set any threads ffmpeg will chose automatically this.command.outputOption('-threads ' + this.threads) @@ -117,7 +122,10 @@ export class FFmpegCommandWrapper { this.command.on('start', cmdline => { shellCommand = cmdline }) this.command.on('error', (err, stdout, stderr) => { - if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags }) + if (silent !== true) this.logger.error('Error in ffmpeg.', { err, stdout, stderr, shellCommand, ...this.lTags }) + + err.stdout = stdout + err.stderr = stderr if (this.onError) this.onError(err) diff --git a/packages/ffmpeg/src/ffmpeg-container.ts b/packages/ffmpeg/src/ffmpeg-container.ts new file mode 100644 index 000000000..0dbf51a72 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-container.ts @@ -0,0 +1,26 @@ +import { Readable, Writable } from 'stream' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' + +export class FFmpegContainer { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + mergeInputs (options: { + inputs: (Readable | string)[] + output: Writable + logError: boolean + }) { + const { inputs, output, logError } = options + + this.commandWrapper.buildCommand(inputs) + .outputOption('-c copy') + .outputOption('-movflags frag_keyframe+empty_moov') + .format('mp4') + .output(output) + + return this.commandWrapper.runCommand({ silent: !logError }) + } +} diff --git a/packages/ffmpeg/src/ffmpeg-edition.ts b/packages/ffmpeg/src/ffmpeg-edition.ts index 021342930..58d865291 100644 --- a/packages/ffmpeg/src/ffmpeg-edition.ts +++ b/packages/ffmpeg/src/ffmpeg-edition.ts @@ -1,7 +1,19 @@ +import { MutexInterface } from 'async-mutex' import { FilterSpecification } from 'fluent-ffmpeg' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' -import { presetVOD } from './shared/presets.js' import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js' +import { presetVOD } from './shared/presets.js' + +type BaseStudioOptions = { + videoInputPath: string + separatedAudioInputPath?: string + + outputPath: string + + // Will be released after the ffmpeg started + // To prevent a bug where the input file does not exist anymore when running ffmpeg + inputFileMutexReleaser?: MutexInterface.Releaser +} export class FFmpegEdition { private readonly commandWrapper: FFmpegCommandWrapper @@ -10,25 +22,27 @@ export class FFmpegEdition { this.commandWrapper = new FFmpegCommandWrapper(options) } - async cutVideo (options: { - inputPath: string - outputPath: string + async cutVideo (options: BaseStudioOptions & { start?: number end?: number }) { - const { inputPath, outputPath } = options + const { videoInputPath, separatedAudioInputPath, outputPath, inputFileMutexReleaser } = options - const mainProbe = await ffprobePromise(inputPath) - const fps = await getVideoStreamFPS(inputPath, mainProbe) - const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) + const mainProbe = await ffprobePromise(videoInputPath) + const fps = await getVideoStreamFPS(videoInputPath, mainProbe) + const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe) - const command = this.commandWrapper.buildCommand(inputPath) + const command = this.commandWrapper.buildCommand(this.buildInputs(options), inputFileMutexReleaser) .output(outputPath) await presetVOD({ commandWrapper: this.commandWrapper, - input: inputPath, + + videoInputPath, + separatedAudioInputPath, + resolution, + videoStreamOnly: false, fps, canCopyAudio: false, canCopyVideo: false @@ -45,10 +59,8 @@ export class FFmpegEdition { await this.commandWrapper.runCommand() } - async addWatermark (options: { - inputPath: string + async addWatermark (options: BaseStudioOptions & { watermarkPath: string - outputPath: string videoFilters: { watermarkSizeRatio: number @@ -56,21 +68,23 @@ export class FFmpegEdition { verticalMarginRatio: number } }) { - const { watermarkPath, inputPath, outputPath, videoFilters } = options + const { watermarkPath, videoInputPath, separatedAudioInputPath, outputPath, videoFilters, inputFileMutexReleaser } = options - const videoProbe = await ffprobePromise(inputPath) - const fps = await getVideoStreamFPS(inputPath, videoProbe) - const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe) + const videoProbe = await ffprobePromise(videoInputPath) + const fps = await getVideoStreamFPS(videoInputPath, videoProbe) + const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe) - const command = this.commandWrapper.buildCommand(inputPath) + const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), watermarkPath ], inputFileMutexReleaser) .output(outputPath) - command.input(watermarkPath) - await presetVOD({ commandWrapper: this.commandWrapper, - input: inputPath, + + videoInputPath, + separatedAudioInputPath, + resolution, + videoStreamOnly: false, fps, canCopyAudio: true, canCopyVideo: false @@ -103,27 +117,24 @@ export class FFmpegEdition { await this.commandWrapper.runCommand() } - async addIntroOutro (options: { - inputPath: string + async addIntroOutro (options: BaseStudioOptions & { introOutroPath: string - outputPath: string + type: 'intro' | 'outro' }) { - const { introOutroPath, inputPath, outputPath, type } = options + const { introOutroPath, videoInputPath, separatedAudioInputPath, outputPath, type, inputFileMutexReleaser } = options - const mainProbe = await ffprobePromise(inputPath) - const fps = await getVideoStreamFPS(inputPath, mainProbe) - const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) - const mainHasAudio = await hasAudioStream(inputPath, mainProbe) + const mainProbe = await ffprobePromise(videoInputPath) + const fps = await getVideoStreamFPS(videoInputPath, mainProbe) + const { resolution } = await getVideoStreamDimensionsInfo(videoInputPath, mainProbe) + const mainHasAudio = await hasAudioStream(separatedAudioInputPath || videoInputPath, mainProbe) const introOutroProbe = await ffprobePromise(introOutroPath) const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe) - const command = this.commandWrapper.buildCommand(inputPath) + const command = this.commandWrapper.buildCommand([ ...this.buildInputs(options), introOutroPath ], inputFileMutexReleaser) .output(outputPath) - command.input(introOutroPath) - if (!introOutroHasAudio && mainHasAudio) { const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe) @@ -134,8 +145,12 @@ export class FFmpegEdition { await presetVOD({ commandWrapper: this.commandWrapper, - input: inputPath, + + videoInputPath, + separatedAudioInputPath, + resolution, + videoStreamOnly: false, fps, canCopyAudio: false, canCopyVideo: false @@ -236,4 +251,11 @@ export class FFmpegEdition { await this.commandWrapper.runCommand() } + + private buildInputs (options: { + videoInputPath: string + separatedAudioInputPath?: string + }) { + return [ options.videoInputPath, options.separatedAudioInputPath ].filter(i => !!i) + } } diff --git a/packages/ffmpeg/src/ffmpeg-live.ts b/packages/ffmpeg/src/ffmpeg-live.ts index 7c3d89b5b..92f33592d 100644 --- a/packages/ffmpeg/src/ffmpeg-live.ts +++ b/packages/ffmpeg/src/ffmpeg-live.ts @@ -1,10 +1,35 @@ import { pick } from '@peertube/peertube-core-utils' -import { FfprobeData, FilterSpecification } from 'fluent-ffmpeg' +import { VideoResolution } from '@peertube/peertube-models' +import { FfmpegCommand, FfprobeData, FilterSpecification } from 'fluent-ffmpeg' import { join } from 'path' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js' import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js' +type LiveTranscodingOptions = { + inputUrl: string + + outPath: string + masterPlaylistName: string + + toTranscode: { + resolution: number + fps: number + }[] + + // Input information + bitrate: number + ratio: number + hasAudio: boolean + hasVideo: boolean + probe: FfprobeData + + segmentListSize: number + segmentDuration: number + + splitAudioAndVideo: boolean +} + export class FFmpegLive { private readonly commandWrapper: FFmpegCommandWrapper @@ -12,132 +37,84 @@ export class FFmpegLive { this.commandWrapper = new FFmpegCommandWrapper(options) } - async getLiveTranscodingCommand (options: { - inputUrl: string + async getLiveTranscodingCommand (options: LiveTranscodingOptions) { + this.commandWrapper.debugLog('Building live transcoding command', options) - outPath: string - masterPlaylistName: string - - toTranscode: { - resolution: number - fps: number - }[] - - // Input information - bitrate: number - ratio: number - hasAudio: boolean - probe: FfprobeData - - segmentListSize: number - segmentDuration: number - }) { const { inputUrl, outPath, toTranscode, - bitrate, masterPlaylistName, - ratio, hasAudio, - probe + splitAudioAndVideo } = options + const command = this.commandWrapper.buildCommand(inputUrl) - const varStreamMap: string[] = [] - - const complexFilter: FilterSpecification[] = [ - { - inputs: '[v:0]', - filter: 'split', - options: toTranscode.length, - outputs: toTranscode.map(t => `vtemp${t.resolution}`) - } - ] + let varStreamMap: string[] = [] command.outputOption('-sc_threshold 0') addDefaultEncoderGlobalParams(command) - for (let i = 0; i < toTranscode.length; i++) { - const streamMap: string[] = [] - const { resolution, fps } = toTranscode[i] + // Audio input only or audio output only + if (this.isAudioInputOrOutputOnly(options)) { + const result = await this.buildTranscodingStream({ + ...options, - const baseEncoderBuilderParams = { - input: inputUrl, + command, + resolution: toTranscode[0].resolution, + fps: toTranscode[0].fps, + streamNum: 0, + // No need to add complexity to the m3u8 playlist, we just provide 1 audio variant stream + splitAudioAndVideo: false, + streamType: 'audio' + }) - canCopyAudio: true, - canCopyVideo: true, + varStreamMap = varStreamMap.concat(result.varStreamMap) + varStreamMap.push(result.streamMap.join(',')) + } else { + // Do not mix video with audio only playlist + // Audio only input/output is already taken into account above + const toTranscodeWithoutAudioOnly = toTranscode.filter(t => t.resolution !== VideoResolution.H_NOVIDEO) - inputBitrate: bitrate, - inputRatio: ratio, - inputProbe: probe, + let complexFilter: FilterSpecification[] = [ + { + inputs: '[v:0]', + filter: 'split', + options: toTranscodeWithoutAudioOnly.length, + outputs: toTranscodeWithoutAudioOnly.map(t => `vtemp${t.resolution}`) + } + ] - resolution, - fps, + let alreadyProcessedAudio = false - streamNum: i, - videoType: 'live' as 'live' - } + for (let i = 0; i < toTranscodeWithoutAudioOnly.length; i++) { + let streamMap: string[] = [] - { - const streamType: StreamType = 'video' + const { resolution, fps } = toTranscodeWithoutAudioOnly[i] - const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) - if (!builderResult) { - throw new Error('No available live video encoder found') + for (const streamType of [ 'audio' as 'audio', 'video' as 'video' ]) { + if (streamType === 'audio') { + if (!hasAudio || (splitAudioAndVideo && alreadyProcessedAudio)) continue + + alreadyProcessedAudio = true + } + + const result = await this.buildTranscodingStream({ ...options, command, resolution, fps, streamNum: i, streamType }) + varStreamMap = varStreamMap.concat(result.varStreamMap) + streamMap = streamMap.concat(result.streamMap) + complexFilter = complexFilter.concat(result.complexFilter) } - command.outputOption(`-map [vout${resolution}]`) - - addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) - - this.commandWrapper.debugLog( - `Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, - { builderResult, fps, toTranscode } - ) - - command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) - applyEncoderOptions(command, builderResult.result) - - complexFilter.push({ - inputs: `vtemp${resolution}`, - filter: getScaleFilter(builderResult.result), - options: `w=-2:h=${resolution}`, - outputs: `vout${resolution}` - }) - - streamMap.push(`v:${i}`) - } - - if (hasAudio) { - const streamType: StreamType = 'audio' - - const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) - if (!builderResult) { - throw new Error('No available live audio encoder found') + if (streamMap.length !== 0) { + varStreamMap.push(streamMap.join(',')) } - - command.outputOption('-map a:0') - - addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) - - this.commandWrapper.debugLog( - `Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, - { builderResult, fps, resolution } - ) - - command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) - applyEncoderOptions(command, builderResult.result) - - streamMap.push(`a:${i}`) } - varStreamMap.push(streamMap.join(',')) + command.complexFilter(complexFilter) } - command.complexFilter(complexFilter) - this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName }) command.outputOption('-var_stream_map', varStreamMap.join(' ')) @@ -145,6 +122,101 @@ export class FFmpegLive { return command } + private isAudioInputOrOutputOnly (options: Pick) { + const { hasAudio, hasVideo, toTranscode } = options + + if (hasAudio && !hasVideo) return true + if (toTranscode.length === 1 && toTranscode[0].resolution === VideoResolution.H_NOVIDEO) return true + + return false + } + + private async buildTranscodingStream ( + options: Pick & { + command: FfmpegCommand + resolution: number + fps: number + streamNum: number + streamType: StreamType + } + ) { + const { inputUrl, bitrate, ratio, probe, splitAudioAndVideo, command, resolution, fps, streamNum, streamType, hasAudio } = options + + const baseEncoderBuilderParams = { + input: inputUrl, + + canCopyAudio: true, + canCopyVideo: true, + + inputBitrate: bitrate, + inputRatio: ratio, + inputProbe: probe, + + resolution, + fps, + + streamNum, + videoType: 'live' as 'live' + } + + const streamMap: string[] = [] + const varStreamMap: string[] = [] + const complexFilter: FilterSpecification[] = [] + + const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) + if (!builderResult) { + throw new Error(`No available live ${streamType} encoder found`) + } + + if (streamType === 'audio') { + command.outputOption('-map a:0') + } else { + command.outputOption(`-map [vout${resolution}]`) + } + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum }) + + this.commandWrapper.debugLog( + `Apply ffmpeg live ${streamType} params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, + { builderResult, fps, resolution } + ) + + if (streamType === 'audio') { + command.outputOption(`${buildStreamSuffix('-c:a', streamNum)} ${builderResult.encoder}`) + + if (splitAudioAndVideo) { + varStreamMap.push(`a:${streamNum},agroup:Audio,default:yes`) + } else { + streamMap.push(`a:${streamNum}`) + } + } else { + command.outputOption(`${buildStreamSuffix('-c:v', streamNum)} ${builderResult.encoder}`) + + complexFilter.push({ + inputs: `vtemp${resolution}`, + filter: getScaleFilter(builderResult.result), + options: `w=-2:h=${resolution}`, + outputs: `vout${resolution}` + }) + + if (splitAudioAndVideo) { + const suffix = hasAudio + ? `,agroup:Audio` + : '' + + varStreamMap.push(`v:${streamNum}${suffix}`) + } else { + streamMap.push(`v:${streamNum}`) + } + } + + applyEncoderOptions(command, builderResult.result) + + return { varStreamMap, streamMap, complexFilter } + } + + // --------------------------------------------------------------------------- + getLiveMuxingCommand (options: { inputUrl: string outPath: string @@ -167,6 +239,8 @@ export class FFmpegLive { return command } + // --------------------------------------------------------------------------- + private addDefaultLiveHLSParams (options: { outPath: string masterPlaylistName: string diff --git a/packages/ffmpeg/src/ffmpeg-vod.ts b/packages/ffmpeg/src/ffmpeg-vod.ts index ba537c39c..6fecbd1e7 100644 --- a/packages/ffmpeg/src/ffmpeg-vod.ts +++ b/packages/ffmpeg/src/ffmpeg-vod.ts @@ -1,19 +1,20 @@ import { pick } from '@peertube/peertube-core-utils' -import { VideoResolution } from '@peertube/peertube-models' import { MutexInterface } from 'async-mutex' import { FfmpegCommand } from 'fluent-ffmpeg' import { readFile, writeFile } from 'fs/promises' import { dirname } from 'path' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js' -import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js' +import { presetCopy, presetVOD } from './shared/presets.js' export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' export interface BaseTranscodeVODOptions { type: TranscodeVODOptionsType - inputPath: string + videoInputPath: string + separatedAudioInputPath?: string + outputPath: string // Will be released after the ffmpeg started @@ -28,6 +29,7 @@ export interface HLSTranscodeOptions extends BaseTranscodeVODOptions { type: 'hls' copyCodecs: boolean + separatedAudio: boolean hlsPlaylist: { videoFilename: string @@ -83,12 +85,14 @@ export class FFmpegVOD { 'hls': this.buildHLSVODCommand.bind(this), 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this), 'merge-audio': this.buildAudioMergeCommand.bind(this), - 'video': this.buildWebVideoCommand.bind(this) + 'video': this.buildVODCommand.bind(this) } this.commandWrapper.debugLog('Will run transcode.', { options }) - this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser) + const inputPaths = [ options.videoInputPath, options.separatedAudioInputPath ].filter(e => !!e) + + this.commandWrapper.buildCommand(inputPaths, options.inputFileMutexReleaser) .output(options.outputPath) await builders[options.type](options) @@ -104,19 +108,26 @@ export class FFmpegVOD { return this.ended } - private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) { - const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = options - - if (resolution === VideoResolution.H_NOVIDEO) { - presetOnlyAudio(this.commandWrapper) - return - } + private async buildVODCommand (options: TranscodeVODOptions & { + videoStreamOnly?: boolean + canCopyAudio?: boolean + canCopyVideo?: boolean + }) { + const { + resolution, + fps, + videoInputPath, + separatedAudioInputPath, + videoStreamOnly = false, + canCopyAudio = true, + canCopyVideo = true + } = options let scaleFilterValue: string - if (resolution !== undefined) { - const probe = await ffprobePromise(inputPath) - const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe) + if (resolution) { + const probe = await ffprobePromise(videoInputPath) + const videoStreamInfo = await getVideoStreamDimensionsInfo(videoInputPath, probe) scaleFilterValue = videoStreamInfo?.isPortraitMode === true ? `w=${resolution}:h=-2` @@ -127,7 +138,11 @@ export class FFmpegVOD { commandWrapper: this.commandWrapper, resolution, - input: inputPath, + videoStreamOnly, + + videoInputPath, + separatedAudioInputPath, + canCopyAudio, canCopyVideo, fps, @@ -157,9 +172,10 @@ export class FFmpegVOD { ...pick(options, [ 'resolution' ]), commandWrapper: this.commandWrapper, - input: options.audioPath, + videoInputPath: options.audioPath, canCopyAudio: true, canCopyVideo: true, + videoStreamOnly: false, fps: options.fps, scaleFilterValue: this.getMergeAudioScaleFilterValue() }) @@ -186,13 +202,16 @@ export class FFmpegVOD { const videoPath = this.getHLSVideoPath(options) if (options.copyCodecs) { - presetCopy(this.commandWrapper) - } else if (options.resolution === VideoResolution.H_NOVIDEO) { - presetOnlyAudio(this.commandWrapper) + presetCopy(this.commandWrapper, { + withAudio: !options.separatedAudio || !options.resolution, + withVideo: !options.separatedAudio || !!options.resolution + }) } else { - // If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync - // See for example https://github.com/Chocobozzz/PeerTube/issues/6438 - await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false }) + await this.buildVODCommand({ + ...options, + + videoStreamOnly: options.separatedAudio && !!options.resolution + }) } this.addCommonHLSVODCommandOptions(command, videoPath) diff --git a/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts index d86ba3d12..d18401586 100644 --- a/packages/ffmpeg/src/ffprobe.ts +++ b/packages/ffmpeg/src/ffprobe.ts @@ -174,6 +174,12 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) { return metadata.streams.find(s => s.codec_type === 'video') } +async function hasVideoStream (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + + return !!videoStream +} + // --------------------------------------------------------------------------- // Chapters // --------------------------------------------------------------------------- @@ -209,5 +215,6 @@ export { isAudioFile, ffprobePromise, getVideoStreamBitrate, - hasAudioStream + hasAudioStream, + hasVideoStream } diff --git a/packages/ffmpeg/src/index.ts b/packages/ffmpeg/src/index.ts index 511409a50..26bb39e21 100644 --- a/packages/ffmpeg/src/index.ts +++ b/packages/ffmpeg/src/index.ts @@ -1,4 +1,5 @@ export * from './ffmpeg-command-wrapper.js' +export * from './ffmpeg-container.js' export * from './ffmpeg-default-transcoding-profile.js' export * from './ffmpeg-edition.js' export * from './ffmpeg-images.js' diff --git a/packages/ffmpeg/src/shared/presets.ts b/packages/ffmpeg/src/shared/presets.ts index 009a65078..a681cf0c1 100644 --- a/packages/ffmpeg/src/shared/presets.ts +++ b/packages/ffmpeg/src/shared/presets.ts @@ -7,7 +7,8 @@ import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOpt export async function presetVOD (options: { commandWrapper: FFmpegCommandWrapper - input: string + videoInputPath: string + separatedAudioInputPath?: string canCopyAudio: boolean canCopyVideo: boolean @@ -15,9 +16,16 @@ export async function presetVOD (options: { resolution: number fps: number + videoStreamOnly: boolean + scaleFilterValue?: string }) { - const { commandWrapper, input, resolution, fps, scaleFilterValue } = options + const { commandWrapper, videoInputPath, separatedAudioInputPath, resolution, fps, videoStreamOnly, scaleFilterValue } = options + + if (videoStreamOnly && !resolution) { + throw new Error('Cannot generate video stream only without valid resolution') + } + const command = commandWrapper.getCommand() command.format('mp4') @@ -25,27 +33,40 @@ export async function presetVOD (options: { addDefaultEncoderGlobalParams(command) - const probe = await ffprobePromise(input) + const videoProbe = await ffprobePromise(videoInputPath) + const audioProbe = separatedAudioInputPath + ? await ffprobePromise(separatedAudioInputPath) + : videoProbe // Audio encoder - const bitrate = await getVideoStreamBitrate(input, probe) - const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe) + const bitrate = await getVideoStreamBitrate(videoInputPath, videoProbe) + const videoStreamDimensions = await getVideoStreamDimensionsInfo(videoInputPath, videoProbe) let streamsToProcess: StreamType[] = [ 'audio', 'video' ] - if (!await hasAudioStream(input, probe)) { + if (videoStreamOnly || !await hasAudioStream(separatedAudioInputPath || videoInputPath, audioProbe)) { command.noAudio() streamsToProcess = [ 'video' ] + } else if (!resolution) { + command.noVideo() + streamsToProcess = [ 'audio' ] } for (const streamType of streamsToProcess) { + const input = streamType === 'video' + ? videoInputPath + : separatedAudioInputPath || videoInputPath + const builderResult = await commandWrapper.getEncoderBuilderResult({ ...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]), input, + inputProbe: streamType === 'video' + ? videoProbe + : audioProbe, + inputBitrate: bitrate, inputRatio: videoStreamDimensions?.ratio || 0, - inputProbe: probe, resolution, fps, @@ -79,16 +100,17 @@ export async function presetVOD (options: { } } -export function presetCopy (commandWrapper: FFmpegCommandWrapper) { - commandWrapper.getCommand() - .format('mp4') - .videoCodec('copy') - .audioCodec('copy') -} +export function presetCopy (commandWrapper: FFmpegCommandWrapper, options: { + withAudio?: boolean // default true + withVideo?: boolean // default true +} = {}) { + const command = commandWrapper.getCommand() -export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) { - commandWrapper.getCommand() - .format('mp4') - .audioCodec('copy') - .noVideo() + command.format('mp4') + + if (options.withAudio === false) command.noAudio() + else command.audioCodec('copy') + + if (options.withVideo === false) command.noVideo() + else command.videoCodec('copy') } diff --git a/packages/models/src/activitypub/objects/common-objects.ts b/packages/models/src/activitypub/objects/common-objects.ts index 6c8fca2ff..ece6e3da9 100644 --- a/packages/models/src/activitypub/objects/common-objects.ts +++ b/packages/models/src/activitypub/objects/common-objects.ts @@ -14,6 +14,18 @@ export interface ActivityIconObject { height: number | null } +// --------------------------------------------------------------------------- + +export type ActivityVideoUrlObjectAttachment = { + type: 'PropertyValue' + name: 'ffprobe_codec_type' + value: 'video' | 'audio' +} | { + type: 'PropertyValue' + name: 'peertube_format_flag' + value: 'web-video' | 'fragmented' +} + export type ActivityVideoUrlObject = { type: 'Link' mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4' @@ -22,8 +34,12 @@ export type ActivityVideoUrlObject = { width: number | null size: number fps: number + + attachment: ActivityVideoUrlObjectAttachment[] } +// --------------------------------------------------------------------------- + export type ActivityPlaylistSegmentHashesObject = { type: 'Link' name: 'sha256' diff --git a/packages/models/src/plugins/server/server-hook.model.ts b/packages/models/src/plugins/server/server-hook.model.ts index 5f6765041..f2024d0c2 100644 --- a/packages/models/src/plugins/server/server-hook.model.ts +++ b/packages/models/src/plugins/server/server-hook.model.ts @@ -106,6 +106,7 @@ export const serverFilterHookObject = { // Filter result used to check if video/torrent download is allowed 'filter:api.download.video.allowed.result': true, + 'filter:api.download.generated-video.allowed.result': true, 'filter:api.download.torrent.allowed.result': true, // Filter result to check if the embed is allowed for a particular request diff --git a/packages/models/src/runners/runner-job-payload.model.ts b/packages/models/src/runners/runner-job-payload.model.ts index 605e216cf..dc0fb3168 100644 --- a/packages/models/src/runners/runner-job-payload.model.ts +++ b/packages/models/src/runners/runner-job-payload.model.ts @@ -16,6 +16,7 @@ export type RunnerJobPayload = export interface RunnerJobVODWebVideoTranscodingPayload { input: { videoFileUrl: string + separatedAudioFileUrl: string[] } output: { @@ -27,11 +28,13 @@ export interface RunnerJobVODWebVideoTranscodingPayload { export interface RunnerJobVODHLSTranscodingPayload { input: { videoFileUrl: string + separatedAudioFileUrl: string[] } output: { resolution: number fps: number + separatedAudio: boolean } } @@ -50,6 +53,7 @@ export interface RunnerJobVODAudioMergeTranscodingPayload { export interface RunnerJobStudioTranscodingPayload { input: { videoFileUrl: string + separatedAudioFileUrl: string[] } tasks: VideoStudioTaskPayload[] diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts index f053dc6ac..b085524f6 100644 --- a/packages/models/src/server/custom-config.model.ts +++ b/packages/models/src/server/custom-config.model.ts @@ -2,6 +2,7 @@ import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { BroadcastMessageLevel } from './broadcast-message-level.type.js' export type ConfigResolutions = { + '0p': boolean '144p': boolean '240p': boolean '360p': boolean @@ -133,7 +134,7 @@ export interface CustomConfig { profile: string - resolutions: ConfigResolutions & { '0p': boolean } + resolutions: ConfigResolutions alwaysTranscodeOriginalResolution: boolean @@ -143,6 +144,7 @@ export interface CustomConfig { hls: { enabled: boolean + splitAudioAndVideo: boolean } } diff --git a/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts index 8402f6cb4..a6534a1b7 100644 --- a/packages/models/src/server/job.model.ts +++ b/packages/models/src/server/job.model.ts @@ -147,6 +147,7 @@ export type ManageVideoTorrentPayload = interface BaseTranscodingPayload { videoUUID: string + hasChildren?: boolean isNewVideo?: boolean } @@ -156,6 +157,8 @@ export interface HLSTranscodingPayload extends BaseTranscodingPayload { fps: number copyCodecs: boolean + separatedAudio: boolean + deleteWebVideoFiles: boolean } @@ -170,16 +173,12 @@ export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { resolution: number fps: number - - hasChildren: boolean } export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { type: 'optimize-to-web-video' quickTranscode: boolean - - hasChildren: boolean } export type VideoTranscodingPayload = diff --git a/packages/models/src/videos/file/index.ts b/packages/models/src/videos/file/index.ts index ee06f4e20..3afcb21a8 100644 --- a/packages/models/src/videos/file/index.ts +++ b/packages/models/src/videos/file/index.ts @@ -1,3 +1,5 @@ export * from './video-file-metadata.model.js' export * from './video-file.model.js' export * from './video-resolution.enum.js' +export * from './video-file-format-flag.enum.js' +export * from './video-file-stream.enum.js' diff --git a/packages/models/src/videos/file/video-file-format-flag.enum.ts b/packages/models/src/videos/file/video-file-format-flag.enum.ts new file mode 100644 index 000000000..d4dbf1c7d --- /dev/null +++ b/packages/models/src/videos/file/video-file-format-flag.enum.ts @@ -0,0 +1,7 @@ +export const VideoFileFormatFlag = { + NONE: 0, + WEB_VIDEO: 1 << 0, + FRAGMENTED: 1 << 1 +} as const + +export type VideoFileFormatFlagType = typeof VideoFileFormatFlag[keyof typeof VideoFileFormatFlag] diff --git a/packages/models/src/videos/file/video-file-stream.enum.ts b/packages/models/src/videos/file/video-file-stream.enum.ts new file mode 100644 index 000000000..9467e51f0 --- /dev/null +++ b/packages/models/src/videos/file/video-file-stream.enum.ts @@ -0,0 +1,7 @@ +export const VideoFileStream = { + NONE: 0, + VIDEO: 1 << 0, + AUDIO: 1 << 1 +} as const + +export type VideoFileStreamType = typeof VideoFileStream[keyof typeof VideoFileStream] diff --git a/packages/models/src/videos/file/video-file.model.ts b/packages/models/src/videos/file/video-file.model.ts index 9745eb752..b78fd6503 100644 --- a/packages/models/src/videos/file/video-file.model.ts +++ b/packages/models/src/videos/file/video-file.model.ts @@ -22,4 +22,7 @@ export interface VideoFile { metadataUrl?: string magnetUri: string | null + + hasAudio: boolean + hasVideo: boolean } diff --git a/packages/server-commands/src/server/config-command.ts b/packages/server-commands/src/server/config-command.ts index ed648818e..ea3752712 100644 --- a/packages/server-commands/src/server/config-command.ts +++ b/packages/server-commands/src/server/config-command.ts @@ -5,7 +5,7 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-comm export class ConfigCommand extends AbstractCommand { - static getCustomConfigResolutions (enabled: boolean, with0p = false) { + static getConfigResolutions (enabled: boolean, with0p = false) { return { '0p': enabled && with0p, '144p': enabled, @@ -19,6 +19,20 @@ export class ConfigCommand extends AbstractCommand { } } + static getCustomConfigResolutions (enabled: number[]) { + return { + '0p': enabled.includes(0), + '144p': enabled.includes(144), + '240p': enabled.includes(240), + '360p': enabled.includes(360), + '480p': enabled.includes(480), + '720p': enabled.includes(720), + '1080p': enabled.includes(1080), + '1440p': enabled.includes(1440), + '2160p': enabled.includes(2160) + } + } + // --------------------------------------------------------------------------- static getEmailOverrideConfig (emailPort: number) { @@ -211,19 +225,27 @@ export class ConfigCommand extends AbstractCommand { enableLive (options: { allowReplay?: boolean + resolutions?: 'min' | 'max' | number[] // default 'min' transcoding?: boolean - resolutions?: 'min' | 'max' // Default max + maxDuration?: number + alwaysTranscodeOriginalResolution?: boolean } = {}) { - const { allowReplay, transcoding, resolutions = 'max' } = options + const { allowReplay, transcoding, maxDuration, resolutions = 'min', alwaysTranscodeOriginalResolution } = options return this.updateExistingConfig({ newConfig: { live: { enabled: true, - allowReplay: allowReplay ?? true, + allowReplay, + maxDuration, transcoding: { - enabled: transcoding ?? true, - resolutions: ConfigCommand.getCustomConfigResolutions(resolutions === 'max') + enabled: transcoding, + + alwaysTranscodeOriginalResolution, + + resolutions: Array.isArray(resolutions) + ? ConfigCommand.getCustomConfigResolutions(resolutions) + : ConfigCommand.getConfigResolutions(resolutions === 'max') } } } @@ -246,10 +268,14 @@ export class ConfigCommand extends AbstractCommand { enableTranscoding (options: { webVideo?: boolean // default true hls?: boolean // default true - with0p?: boolean // default false keepOriginal?: boolean // default false + splitAudioAndVideo?: boolean // default false + + resolutions?: 'min' | 'max' | number[] // default 'max' + + with0p?: boolean // default false } = {}) { - const { webVideo = true, hls = true, with0p = false, keepOriginal = false } = options + const { resolutions = 'max', webVideo = true, hls = true, with0p = false, keepOriginal = false, splitAudioAndVideo = false } = options return this.updateExistingConfig({ newConfig: { @@ -262,25 +288,39 @@ export class ConfigCommand extends AbstractCommand { allowAudioFiles: true, allowAdditionalExtensions: true, - resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p), + resolutions: Array.isArray(resolutions) + ? ConfigCommand.getCustomConfigResolutions(resolutions) + : ConfigCommand.getConfigResolutions(resolutions === 'max', with0p), webVideos: { enabled: webVideo }, hls: { - enabled: hls + enabled: hls, + splitAudioAndVideo } } } }) } + setTranscodingConcurrency (concurrency: number) { + return this.updateExistingConfig({ + newConfig: { + transcoding: { + concurrency + } + } + }) + } + enableMinimumTranscoding (options: { webVideo?: boolean // default true hls?: boolean // default true + splitAudioAndVideo?: boolean // default false keepOriginal?: boolean // default false } = {}) { - const { webVideo = true, hls = true, keepOriginal = false } = options + const { webVideo = true, hls = true, keepOriginal = false, splitAudioAndVideo = false } = options return this.updateExistingConfig({ newConfig: { @@ -294,7 +334,7 @@ export class ConfigCommand extends AbstractCommand { allowAdditionalExtensions: true, resolutions: { - ...ConfigCommand.getCustomConfigResolutions(false), + ...ConfigCommand.getConfigResolutions(false), '240p': true }, @@ -303,7 +343,8 @@ export class ConfigCommand extends AbstractCommand { enabled: webVideo }, hls: { - enabled: hls + enabled: hls, + splitAudioAndVideo } } } diff --git a/packages/server-commands/src/server/follows.ts b/packages/server-commands/src/server/follows.ts index 32304495a..32c5b32fe 100644 --- a/packages/server-commands/src/server/follows.ts +++ b/packages/server-commands/src/server/follows.ts @@ -1,7 +1,7 @@ import { waitJobs } from './jobs.js' import { PeerTubeServer } from './server.js' -async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { +export async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { await Promise.all([ server1.follows.follow({ hosts: [ server2.url ] }), server2.follows.follow({ hosts: [ server1.url ] }) @@ -9,12 +9,18 @@ async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { // Wait request propagation await waitJobs([ server1, server2 ]) - - return true } -// --------------------------------------------------------------------------- +export function followAll (servers: PeerTubeServer[]) { + const p: Promise[] = [] -export { - doubleFollow + for (const server of servers) { + for (const remoteServer of servers) { + if (server === remoteServer) continue + + p.push(doubleFollow(server, remoteServer)) + } + } + + return Promise.all(p) } diff --git a/packages/server-commands/src/server/jobs.ts b/packages/server-commands/src/server/jobs.ts index eca2a865a..06c5e7ef9 100644 --- a/packages/server-commands/src/server/jobs.ts +++ b/packages/server-commands/src/server/jobs.ts @@ -29,7 +29,7 @@ async function waitJobs ( // Check if each server has pending request for (const server of servers) { - if (process.env.DEBUG) console.log('Checking ' + server.url) + if (process.env.DEBUG) console.log(`${new Date().toISOString()} - Checking ${server.url}`) for (const state of states) { @@ -45,7 +45,7 @@ async function waitJobs ( pendingRequests = true if (process.env.DEBUG) { - console.log(jobs) + console.log(`${new Date().toISOString()}`, jobs) } } }) @@ -59,7 +59,7 @@ async function waitJobs ( pendingRequests = true if (process.env.DEBUG) { - console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting) + console.log(`${new Date().toISOString()} - AP messages waiting: ${obj.activityPubMessagesWaiting}`) } } }) @@ -73,7 +73,7 @@ async function waitJobs ( pendingRequests = true if (process.env.DEBUG) { - console.log(job) + console.log(`${new Date().toISOString()}`, job) } } } diff --git a/packages/server-commands/src/users/accounts.ts b/packages/server-commands/src/users/accounts.ts index 15ad0ef52..0afe5650c 100644 --- a/packages/server-commands/src/users/accounts.ts +++ b/packages/server-commands/src/users/accounts.ts @@ -4,7 +4,7 @@ import { PeerTubeServer } from '../server/server.js' export async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) { const servers = arrayify(serversArg) - for (const server of servers) { - await server.users.updateMyAvatar({ fixture: 'avatar.png', token }) - } + return Promise.all( + servers.map(s => s.users.updateMyAvatar({ fixture: 'avatar.png', token })) + ) } diff --git a/packages/server-commands/src/videos/channels.ts b/packages/server-commands/src/videos/channels.ts index 52f3a2265..829bae83a 100644 --- a/packages/server-commands/src/videos/channels.ts +++ b/packages/server-commands/src/videos/channels.ts @@ -2,22 +2,18 @@ import { arrayify } from '@peertube/peertube-core-utils' import { PeerTubeServer } from '../server/server.js' export function setDefaultVideoChannel (servers: PeerTubeServer[]) { - const tasks: Promise[] = [] - - for (const server of servers) { - const p = server.users.getMyInfo() - .then(user => { server.store.channel = user.videoChannels[0] }) - - tasks.push(p) - } - - return Promise.all(tasks) + return Promise.all( + servers.map(s => { + return s.users.getMyInfo() + .then(user => { s.store.channel = user.videoChannels[0] }) + }) + ) } export async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') { const servers = arrayify(serversArg) - for (const server of servers) { - await server.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' }) - } + return Promise.all( + servers.map(s => s.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' })) + ) } diff --git a/packages/server-commands/src/videos/live-command.ts b/packages/server-commands/src/videos/live-command.ts index 965ff2e9e..407d9dc73 100644 --- a/packages/server-commands/src/videos/live-command.ts +++ b/packages/server-commands/src/videos/live-command.ts @@ -167,6 +167,7 @@ export class LiveCommand extends AbstractCommand { async runAndTestStreamError (options: OverrideCommandOptions & { videoId: number | string shouldHaveError: boolean + fixtureName?: string }) { const command = await this.sendRTMPStreamInVideo(options) diff --git a/packages/server-commands/src/videos/live.ts b/packages/server-commands/src/videos/live.ts index 05bfa1113..274c64d40 100644 --- a/packages/server-commands/src/videos/live.ts +++ b/packages/server-commands/src/videos/live.ts @@ -5,7 +5,7 @@ import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' import truncate from 'lodash-es/truncate.js' import { PeerTubeServer } from '../server/server.js' -function sendRTMPStream (options: { +export function sendRTMPStream (options: { rtmpBaseUrl: string streamKey: string fixtureName?: string // default video_short.mp4 @@ -49,7 +49,7 @@ function sendRTMPStream (options: { return command } -function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { +export function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { return new Promise((res, rej) => { command.on('error', err => { return rej(err) @@ -61,7 +61,7 @@ function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { }) } -async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) { +export async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) { let error: Error try { @@ -76,31 +76,39 @@ async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: b if (!shouldHaveError && error) throw error } -async function stopFfmpeg (command: FfmpegCommand) { +export async function stopFfmpeg (command: FfmpegCommand) { command.kill('SIGINT') await wait(500) } -async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) { +export async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) { for (const server of servers) { await server.live.waitUntilPublished({ videoId }) } } -async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) { +export async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) { for (const server of servers) { await server.live.waitUntilWaiting({ videoId }) } } -async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) { +export async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) { for (const server of servers) { await server.live.waitUntilReplacedByReplay({ videoId }) } } -async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) { +export async function findExternalSavedVideo (server: PeerTubeServer, liveVideoUUID: string) { + let liveDetails: VideoDetails + + try { + liveDetails = await server.videos.getWithToken({ id: liveVideoUUID }) + } catch { + return undefined + } + const include = VideoInclude.BLACKLISTED const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ] @@ -114,16 +122,3 @@ async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: Vide return data.find(v => v.name === toFind) } - -export { - sendRTMPStream, - waitFfmpegUntilError, - testFfmpegStreamError, - stopFfmpeg, - - waitUntilLivePublishedOnAllServers, - waitUntilLiveReplacedByReplayOnAllServers, - waitUntilLiveWaitingOnAllServers, - - findExternalSavedVideo -} diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts index 8397b8f4d..ddafc0d3f 100644 --- a/packages/server-commands/src/videos/videos-command.ts +++ b/packages/server-commands/src/videos/videos-command.ts @@ -341,6 +341,14 @@ export class VideosCommand extends AbstractCommand { return data.find(v => v.name === options.name) } + async findFull (options: OverrideCommandOptions & { + name: string + }) { + const { uuid } = await this.find(options) + + return this.get({ id: uuid }) + } + // --------------------------------------------------------------------------- update (options: OverrideCommandOptions & { @@ -662,4 +670,25 @@ export class VideosCommand extends AbstractCommand { endVideoResumableUpload (options: Parameters[0]) { return super.endResumableUpload(options) } + + // --------------------------------------------------------------------------- + + generateDownload (options: OverrideCommandOptions & { + videoId: number | string + videoFileIds: number[] + query?: Record + }) { + const { videoFileIds, videoId, query = {} } = options + const path = '/download/videos/generate/' + videoId + + return this.getRequestBody({ + ...options, + + path, + query: { videoFileIds, ...query }, + responseType: 'arraybuffer', + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } } diff --git a/packages/tests/fixtures/transcription/videos/the_last_man_on_earth.mp4 b/packages/tests/fixtures/transcription/videos/the_last_man_on_earth.mp4 index 45ef4325e..dab65e581 100644 Binary files a/packages/tests/fixtures/transcription/videos/the_last_man_on_earth.mp4 and b/packages/tests/fixtures/transcription/videos/the_last_man_on_earth.mp4 differ diff --git a/packages/tests/src/api/check-params/generate-download.ts b/packages/tests/src/api/check-params/generate-download.ts new file mode 100644 index 000000000..c6ea75653 --- /dev/null +++ b/packages/tests/src/api/check-params/generate-download.ts @@ -0,0 +1,136 @@ +import { getHLS } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, + createSingleServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test generate download API validator', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + }) + + describe('Download rights', function () { + let videoFileToken: string + let videoId: string + let videoFileIds: number[] + + let user3: string + let user4: string + + before(async function () { + this.timeout(60000) + + user3 = await server.users.generateUserAndToken('user3') + user4 = await server.users.generateUserAndToken('user4') + + const { uuid } = await server.videos.quickUpload({ name: 'video', token: user3, privacy: VideoPrivacy.PRIVATE }) + videoId = uuid + + videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid, token: user3 }) + + const video = await server.videos.getWithToken({ id: uuid }) + videoFileIds = [ video.files[0].id ] + + await waitJobs([ server ]) + }) + + it('Should fail without header token or video file token', async function () { + await server.videos.generateDownload({ videoId, videoFileIds, token: null, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an invalid header token', async function () { + await server.videos.generateDownload({ videoId, videoFileIds, token: 'toto', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with an invalid video file token', async function () { + const query = { videoFileToken: 'toto' } + + await server.videos.generateDownload({ videoId, videoFileIds, token: null, query, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with header token of another user', async function () { + await server.videos.generateDownload({ videoId, videoFileIds, token: user4, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with video file token of another user', async function () { + const { uuid: otherVideo } = await server.videos.quickUpload({ name: 'other video' }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: otherVideo, token: user4 }) + const query = { videoFileToken } + + await server.videos.generateDownload({ videoId, videoFileIds, token: null, query, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with a valid header token', async function () { + await server.videos.generateDownload({ videoId, videoFileIds, token: user3 }) + }) + + it('Should succeed with a valid query token', async function () { + await server.videos.generateDownload({ videoId, videoFileIds, token: null, query: { videoFileToken } }) + }) + }) + + describe('Download params', function () { + let videoId: string + let videoStreamIds: number[] + let audioStreamId: number + + before(async function () { + this.timeout(60000) + + await server.config.enableMinimumTranscoding({ hls: true, splitAudioAndVideo: true }) + + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + videoId = uuid + + await waitJobs([ server ]) + + const video = await server.videos.get({ id: uuid }) + + videoStreamIds = getHLS(video).files.filter(f => !f.hasAudio).map(f => f.id) + audioStreamId = getHLS(video).files.find(f => !!f.hasAudio).id + }) + + it('Should fail with invalid video id', async function () { + await server.videos.generateDownload({ videoId: 42, videoFileIds: [ 41 ], expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with invalid videoFileIds query', async function () { + const tests = [ + undefined, + [], + [ 40, 41, 42 ] + ] + + for (const videoFileIds of tests) { + await server.videos.generateDownload({ videoId, videoFileIds, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + + it('Should fail with multiple video files', async function () { + const videoFileIds = videoStreamIds + + await server.videos.generateDownload({ videoId, videoFileIds, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should suceed with the correct params', async function () { + const videoFileIds = [ audioStreamId, videoStreamIds[0] ] + + await server.videos.generateDownload({ videoId, videoFileIds }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts index 17fc996c9..a045490b1 100644 --- a/packages/tests/src/api/check-params/index.ts +++ b/packages/tests/src/api/check-params/index.ts @@ -9,6 +9,7 @@ import './contact-form.js' import './custom-pages.js' import './debug.js' import './follows.js' +import './generate-download.js' import './jobs.js' import './live.js' import './logs.js' diff --git a/packages/tests/src/api/check-params/runners.ts b/packages/tests/src/api/check-params/runners.ts index dd2d2f0a1..04ed8b5cb 100644 --- a/packages/tests/src/api/check-params/runners.ts +++ b/packages/tests/src/api/check-params/runners.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { basename } from 'path' import { HttpStatusCode, HttpStatusCodeType, @@ -12,7 +11,6 @@ import { VideoPrivacy, VideoStudioTaskIntro } from '@peertube/peertube-models' -import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' import { cleanupTests, createSingleServer, @@ -25,6 +23,8 @@ import { VideoStudioCommand, waitJobs } from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { basename } from 'path' const badUUID = '910ec12a-d9e6-458b-a274-0abb655f9464' @@ -66,7 +66,7 @@ describe('Test managing runners', function () { registrationToken = data[0].registrationToken registrationTokenId = data[0].id - await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'min' }) await server.config.enableStudio() await server.config.enableRemoteTranscoding() await server.config.enableRemoteStudio() @@ -452,7 +452,7 @@ describe('Test managing runners', function () { const { uuid } = await server.videos.quickUpload({ name: 'video studio' }) videoStudioUUID = uuid - await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'min' }) await server.config.enableStudio() await server.videoStudio.createEditionTasks({ diff --git a/packages/tests/src/api/check-params/video-files.ts b/packages/tests/src/api/check-params/video-files.ts index b5819ff19..57882881b 100644 --- a/packages/tests/src/api/check-params/video-files.ts +++ b/packages/tests/src/api/check-params/video-files.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { getAllFiles } from '@peertube/peertube-core-utils' -import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { getAllFiles, getHLS } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' import { cleanupTests, createMultipleServers, @@ -73,9 +73,14 @@ describe('Test videos files', function () { let remoteHLSFileId: number let remoteWebVideoFileId: number + let splittedHLSId: string + let hlsWithAudioId: string + before(async function () { this.timeout(300_000) + const resolutions = [ VideoResolution.H_NOVIDEO, VideoResolution.H_144P, VideoResolution.H_240P ] + { const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) await waitJobs(servers) @@ -87,7 +92,7 @@ describe('Test videos files', function () { } { - await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, resolutions }) { const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) @@ -103,22 +108,43 @@ describe('Test videos files', function () { const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) validId2 = uuid } + + await waitJobs(servers) } - await waitJobs(servers) - { - await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: false, resolutions }) const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) hlsId = uuid + + await waitJobs(servers) } - await waitJobs(servers) - { - await servers[0].config.enableTranscoding({ webVideo: true, hls: false }) + await servers[0].config.enableTranscoding({ webVideo: true, hls: false, resolutions }) const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' }) webVideoId = uuid + + await waitJobs(servers) + } + + { + await servers[0].config.enableTranscoding({ webVideo: true, hls: true, splitAudioAndVideo: true, resolutions }) + const { uuid } = await servers[0].videos.quickUpload({ name: 'splitted-audio-video' }) + splittedHLSId = uuid + + await waitJobs(servers) + } + + { + await servers[0].config.enableTranscoding({ + webVideo: true, + hls: true, + splitAudioAndVideo: false, + resolutions + }) + const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' }) + hlsWithAudioId = uuid } await waitJobs(servers) @@ -168,9 +194,6 @@ describe('Test videos files', function () { }) it('Should not delete files if the files are not available', async function () { - await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) }) @@ -187,6 +210,40 @@ describe('Test videos files', function () { await servers[0].videos.removeHLSPlaylist({ videoId: validId1 }) await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 }) }) + + it('Should not delete audio if audio and video is splitted', async function () { + const video = await servers[0].videos.get({ id: splittedHLSId }) + const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO) + + await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: audio.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should be able to delete audio if audio is the latest resolution', async function () { + const video = await servers[0].videos.get({ id: splittedHLSId }) + const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO) + + for (const file of getHLS(video).files) { + if (file.resolution.id === VideoResolution.H_NOVIDEO) continue + + await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: file.id }) + } + + await servers[0].videos.removeHLSFile({ videoId: splittedHLSId, fileId: audio.id }) + }) + + it('Should be able to delete audio of web video', async function () { + const video = await servers[0].videos.get({ id: splittedHLSId }) + const audio = video.files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO) + + await servers[0].videos.removeWebVideoFile({ videoId: splittedHLSId, fileId: audio.id }) + }) + + it('Should be able to delete audio if audio and video are not splitted', async function () { + const video = await servers[0].videos.get({ id: hlsWithAudioId }) + const audio = getHLS(video).files.find(f => f.resolution.id === VideoResolution.H_NOVIDEO) + + await servers[0].videos.removeHLSFile({ videoId: hlsWithAudioId, fileId: audio.id }) + }) }) after(async function () { diff --git a/packages/tests/src/api/check-params/video-source.ts b/packages/tests/src/api/check-params/video-source.ts index 466e59fd5..4f25325e7 100644 --- a/packages/tests/src/api/check-params/video-source.ts +++ b/packages/tests/src/api/check-params/video-source.ts @@ -204,7 +204,7 @@ describe('Test video sources API validator', function () { await makeRawRequest({ url: source.fileDownloadUrl, token: user3, expectedStatus: HttpStatusCode.OK_200 }) }) - it('Should succeed with a valid header token', async function () { + it('Should succeed with a valid query token', async function () { await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) }) }) diff --git a/packages/tests/src/api/live/index.ts b/packages/tests/src/api/live/index.ts index bb5177d95..a47ffdcd6 100644 --- a/packages/tests/src/api/live/index.ts +++ b/packages/tests/src/api/live/index.ts @@ -1,8 +1,9 @@ import './live-constraints.js' import './live-fast-restream.js' -import './live-socket-messages.js' -import './live-privacy-update.js' import './live-permanent.js' +import './live-privacy-update.js' import './live-rtmps.js' import './live-save-replay.js' +import './live-socket-messages.js' +import './live-audio-or-video-only.js' import './live.js' diff --git a/packages/tests/src/api/live/live-audio-or-video-only.ts b/packages/tests/src/api/live/live-audio-or-video-only.ts new file mode 100644 index 000000000..b5d5dbb1f --- /dev/null +++ b/packages/tests/src/api/live/live-audio-or-video-only.ts @@ -0,0 +1,236 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { Video, VideoResolution } from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, createMultipleServers, + doubleFollow, + findExternalSavedVideo, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs, + waitUntilLivePublishedOnAllServers, + waitUntilLiveReplacedByReplayOnAllServers, + waitUntilLiveWaitingOnAllServers +} from '@peertube/peertube-server-commands' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' +import { checkLiveCleanup, testLiveVideoResolutions } from '../../shared/live.js' + +describe('Test live audio only (input or output)', function () { + let servers: PeerTubeServer[] = [] + let sqlCommandServer1: SQLCommand + + function updateConf (transcodingEnabled: boolean, resolutions?: number[]) { + return servers[0].config.enableLive({ + allowReplay: true, + resolutions: resolutions ?? 'min', + alwaysTranscodeOriginalResolution: false, + transcoding: transcodingEnabled, + maxDuration: -1 + }) + } + + async function runAndCheckAudioLive (options: { + permanentLive: boolean + saveReplay: boolean + transcoded: boolean + mode: 'video-only' | 'audio-only' + fixture?: string + resolutions?: number[] + }) { + const { transcoded, permanentLive, saveReplay, mode } = options + + const { video: liveVideo } = await servers[0].live.quickCreate({ permanentLive, saveReplay }) + + let fixtureName = options.fixture + let resolutions = options.resolutions + + if (mode === 'audio-only') { + if (!fixtureName) fixtureName = 'sample.ogg' + if (!resolutions) resolutions = [ VideoResolution.H_NOVIDEO ] + } else if (mode === 'video-only') { + if (!fixtureName) fixtureName = 'video_short_no_audio.mp4' + if (!resolutions) resolutions = [ VideoResolution.H_720P ] + } + + const hasVideo = mode === 'video-only' + const hasAudio = mode === 'audio-only' + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideo.uuid, fixtureName }) + await waitUntilLivePublishedOnAllServers(servers, liveVideo.uuid) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: liveVideo.uuid, + resolutions, + hasAudio, + hasVideo, + transcoded + }) + + await stopFfmpeg(ffmpegCommand) + + return liveVideo + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.enableMinimumTranscoding() + await servers[0].config.enableLive({ allowReplay: true, transcoding: true }) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + sqlCommandServer1 = new SQLCommand(servers[0]) + }) + + describe('Audio input only', function () { + let liveVideo: Video + + it('Should mux an audio input only', async function () { + this.timeout(120000) + + await updateConf(false) + await runAndCheckAudioLive({ mode: 'audio-only', permanentLive: false, saveReplay: false, transcoded: false }) + }) + + it('Should correctly handle an audio input only', async function () { + this.timeout(120000) + + await updateConf(true) + liveVideo = await runAndCheckAudioLive({ mode: 'audio-only', permanentLive: true, saveReplay: true, transcoded: true }) + }) + + it('Should save the replay of an audio input only in a permanent live', async function () { + this.timeout(120000) + + await waitUntilLiveWaitingOnAllServers(servers, liveVideo.uuid) + await waitJobs(servers) + + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideo.uuid, permanent: true }) + + const video = await findExternalSavedVideo(servers[0], liveVideo.uuid) + + await completeCheckHlsPlaylist({ + hlsOnly: true, + servers, + videoUUID: video.uuid, + resolutions: [ 0 ], + hasVideo: false, + splittedAudio: false // audio is not splitted because we only have an audio stream + }) + }) + }) + + describe('Audio output only', function () { + let liveVideo: Video + + before(async function () { + await updateConf(true, [ VideoResolution.H_NOVIDEO ]) + }) + + it('Should correctly handle an audio output only with an audio input only', async function () { + this.timeout(120000) + + await runAndCheckAudioLive({ mode: 'audio-only', permanentLive: false, saveReplay: false, transcoded: true }) + }) + + it('Should correctly handle an audio output only with a video & audio input', async function () { + this.timeout(120000) + + liveVideo = await runAndCheckAudioLive({ + mode: 'audio-only', + fixture: 'video_short.mp4', + permanentLive: false, + saveReplay: true, + transcoded: true + }) + }) + + it('Should save the replay of an audio output only in a normal live', async function () { + this.timeout(120000) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, liveVideo.uuid) + await waitJobs(servers) + + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideo.uuid, permanent: false, savedResolutions: [ 0 ] }) + + await completeCheckHlsPlaylist({ + hlsOnly: true, + servers, + videoUUID: liveVideo.uuid, + resolutions: [ 0 ], + hasVideo: false, + splittedAudio: false // audio is not splitted because we only have an audio stream + }) + }) + + it('Should handle a video input only even if there is only the audio output', async function () { + this.timeout(120000) + + await runAndCheckAudioLive({ + mode: 'video-only', + permanentLive: false, + saveReplay: false, + transcoded: true, + resolutions: [ VideoResolution.H_720P ] + }) + }) + }) + + describe('Video input only', function () { + let liveVideo: Video + + it('Should correctly handle a video input only', async function () { + this.timeout(120000) + + await updateConf(true, [ VideoResolution.H_NOVIDEO, VideoResolution.H_240P ]) + + liveVideo = await runAndCheckAudioLive({ + mode: 'video-only', + permanentLive: true, + saveReplay: true, + transcoded: true, + resolutions: [ VideoResolution.H_240P ] + }) + }) + + it('Should save the replay of a video output only in a permanent live', async function () { + this.timeout(120000) + + await waitUntilLiveWaitingOnAllServers(servers, liveVideo.uuid) + await waitJobs(servers) + + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideo.uuid, permanent: true }) + + const video = await findExternalSavedVideo(servers[0], liveVideo.uuid) + + await completeCheckHlsPlaylist({ + hlsOnly: true, + servers, + videoUUID: video.uuid, + resolutions: [ VideoResolution.H_240P ], + hasAudio: false, + splittedAudio: false // audio is not splitted because we only have a video stream + }) + }) + }) + + after(async function () { + if (sqlCommandServer1) await sqlCommandServer1.cleanup() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/live/live-constraints.ts b/packages/tests/src/api/live/live-constraints.ts index b96a7ceb2..60906e346 100644 --- a/packages/tests/src/api/live/live-constraints.ts +++ b/packages/tests/src/api/live/live-constraints.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { wait } from '@peertube/peertube-core-utils' -import { LiveVideoError, UserVideoQuota, VideoPrivacy } from '@peertube/peertube-models' +import { LiveVideoError, UserVideoQuota, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' import { PeerTubeServer, cleanupTests, createMultipleServers, @@ -38,14 +38,14 @@ describe('Test live constraints', function () { return uuid } - async function checkSaveReplay (videoId: string, resolutions = [ 720 ]) { + async function checkSaveReplay (videoId: string, savedResolutions?: number[]) { for (const server of servers) { const video = await server.videos.get({ id: videoId }) expect(video.isLive).to.be.false expect(video.duration).to.be.greaterThan(0) } - await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions: resolutions }) + await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions }) } function updateQuota (options: { total: number, daily: number }) { @@ -100,7 +100,7 @@ describe('Test live constraints', function () { await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) await waitJobs(servers) - await checkSaveReplay(userVideoLiveoId) + await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ]) const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) @@ -136,7 +136,7 @@ describe('Test live constraints', function () { await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) await waitJobs(servers) - await checkSaveReplay(userVideoLiveoId) + await checkSaveReplay(userVideoLiveoId, [ VideoResolution.H_720P ]) const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) @@ -223,7 +223,7 @@ describe('Test live constraints', function () { await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) await waitJobs(servers) - await checkSaveReplay(userVideoLiveoId, [ 720, 240, 144 ]) + await checkSaveReplay(userVideoLiveoId, [ 720, 240, 144, 0 ]) const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED) diff --git a/packages/tests/src/api/live/live-permanent.ts b/packages/tests/src/api/live/live-permanent.ts index b6ad0b012..2206887d3 100644 --- a/packages/tests/src/api/live/live-permanent.ts +++ b/packages/tests/src/api/live/live-permanent.ts @@ -61,7 +61,7 @@ describe('Permanent live', function () { maxDuration: -1, transcoding: { enabled: true, - resolutions: ConfigCommand.getCustomConfigResolutions(true) + resolutions: ConfigCommand.getConfigResolutions(true) } } } @@ -152,7 +152,7 @@ describe('Permanent live', function () { maxDuration: -1, transcoding: { enabled: true, - resolutions: ConfigCommand.getCustomConfigResolutions(false) + resolutions: ConfigCommand.getConfigResolutions(false) } } } @@ -167,8 +167,8 @@ describe('Permanent live', function () { await checkVideoState(videoUUID, VideoState.PUBLISHED) const count = await servers[0].live.countPlaylists({ videoUUID }) - // master playlist and 720p playlist - expect(count).to.equal(2) + // master playlist, 720p playlist and audio only playlist + expect(count).to.equal(3) await stopFfmpeg(ffmpegCommand) }) diff --git a/packages/tests/src/api/live/live-save-replay.ts b/packages/tests/src/api/live/live-save-replay.ts index 6fc049f4d..cc8cbe410 100644 --- a/packages/tests/src/api/live/live-save-replay.ts +++ b/packages/tests/src/api/live/live-save-replay.ts @@ -155,7 +155,7 @@ describe('Save replay setting', function () { maxDuration: -1, transcoding: { enabled: false, - resolutions: ConfigCommand.getCustomConfigResolutions(true) + resolutions: ConfigCommand.getConfigResolutions(true) } } } @@ -422,14 +422,12 @@ describe('Save replay setting', function () { it('Should correctly have saved the live', async function () { this.timeout(120000) - const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) - await stopFfmpeg(ffmpegCommand) await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) await waitJobs(servers) - const video = await findExternalSavedVideo(servers[0], liveDetails) + const video = await findExternalSavedVideo(servers[0], liveVideoUUID) expect(video).to.exist await servers[0].videos.get({ id: video.uuid }) @@ -508,14 +506,12 @@ describe('Save replay setting', function () { it('Should correctly have saved the live and federated it after the streaming', async function () { this.timeout(120000) - const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) - await stopFfmpeg(ffmpegCommand) await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) await waitJobs(servers) - const video = await findExternalSavedVideo(servers[0], liveDetails) + const video = await findExternalSavedVideo(servers[0], liveVideoUUID) expect(video).to.exist for (const server of servers) { @@ -569,7 +565,7 @@ describe('Save replay setting', function () { replaySettings: { privacy: VideoPrivacy.PUBLIC } }) - const replay = await findExternalSavedVideo(servers[0], liveDetails) + const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid) expect(replay).to.exist for (const videoId of [ liveVideoUUID, replay.uuid ]) { @@ -591,7 +587,7 @@ describe('Save replay setting', function () { replaySettings: { privacy: VideoPrivacy.PUBLIC } }) - const replay = await findExternalSavedVideo(servers[0], liveDetails) + const replay = await findExternalSavedVideo(servers[0], liveDetails.uuid) expect(replay).to.not.exist await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.NOT_FOUND_404) diff --git a/packages/tests/src/api/live/live.ts b/packages/tests/src/api/live/live.ts index fa5862088..a9ebff505 100644 --- a/packages/tests/src/api/live/live.ts +++ b/packages/tests/src/api/live/live.ts @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { basename, join } from 'path' import { getAllFiles, wait } from '@peertube/peertube-core-utils' -import { ffprobePromise, getVideoStream } from '@peertube/peertube-ffmpeg' +import { ffprobePromise } from '@peertube/peertube-ffmpeg' import { HttpStatusCode, LiveVideo, @@ -12,6 +10,7 @@ import { VideoCommentPolicy, VideoDetails, VideoPrivacy, + VideoResolution, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models' @@ -35,6 +34,8 @@ import { import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' import { testLiveVideoResolutions } from '@tests/shared/live.js' import { SQLCommand } from '@tests/shared/sql-command.js' +import { expect } from 'chai' +import { basename, join } from 'path' describe('Test live', function () { let servers: PeerTubeServer[] = [] @@ -399,38 +400,22 @@ describe('Test live', function () { } function updateConf (resolutions: number[]) { - return servers[0].config.updateExistingConfig({ - newConfig: { - live: { - enabled: true, - allowReplay: true, - maxDuration: -1, - transcoding: { - enabled: true, - resolutions: { - '144p': resolutions.includes(144), - '240p': resolutions.includes(240), - '360p': resolutions.includes(360), - '480p': resolutions.includes(480), - '720p': resolutions.includes(720), - '1080p': resolutions.includes(1080), - '2160p': resolutions.includes(2160) - } - } - } - } + return servers[0].config.enableLive({ + allowReplay: true, + resolutions, + transcoding: true, + maxDuration: -1 }) } before(async function () { - await updateConf([]) - sqlCommandServer1 = new SQLCommand(servers[0]) }) it('Should enable transcoding without additional resolutions', async function () { this.timeout(120000) + await updateConf([]) liveVideoId = await createLiveWrapper(false) const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId }) @@ -449,18 +434,6 @@ describe('Test live', function () { await stopFfmpeg(ffmpegCommand) }) - it('Should transcode audio only RTMP stream', async function () { - this.timeout(120000) - - liveVideoId = await createLiveWrapper(false) - - const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short_no_audio.mp4' }) - await waitUntilLivePublishedOnAllServers(servers, liveVideoId) - await waitJobs(servers) - - await stopFfmpeg(ffmpegCommand) - }) - it('Should enable transcoding with some resolutions', async function () { this.timeout(240000) @@ -541,15 +514,17 @@ describe('Test live', function () { await waitUntilLivePublishedOnAllServers(servers, liveVideoId) const maxBitrateLimits = { - 720: 6500 * 1000, // 60FPS - 360: 1250 * 1000, - 240: 700 * 1000 + 720: 6350 * 1000, // 60FPS + 360: 1100 * 1000, + 240: 600 * 1000, + 0: 170 * 1000 } const minBitrateLimits = { - 720: 4800 * 1000, - 360: 1000 * 1000, - 240: 550 * 1000 + 720: 4650 * 1000, + 360: 850 * 1000, + 240: 400 * 1000, + 0: 100 * 1000 } for (const server of servers) { @@ -568,9 +543,10 @@ describe('Test live', function () { expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8') expect(basename(hlsPlaylist.segmentsSha256Url)).to.not.equal('segments-sha256.json') - expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length) + const resolutionsAndAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ] + expect(hlsPlaylist.files).to.have.lengthOf(resolutionsAndAudio.length) - for (const resolution of resolutions) { + for (const resolution of resolutionsAndAudio) { const file = hlsPlaylist.files.find(f => f.resolution.id === resolution) expect(file).to.exist @@ -578,6 +554,8 @@ describe('Test live', function () { if (resolution >= 720) { expect(file.fps).to.be.approximately(60, 10) + } else if (resolution === VideoResolution.H_NOVIDEO) { + expect(file.fps).to.equal(0) } else { expect(file.fps).to.be.approximately(30, 3) } @@ -588,10 +566,9 @@ describe('Test live', function () { const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename)) const probe = await ffprobePromise(segmentPath) - const videoStream = await getVideoStream(segmentPath, probe) - expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height]) - expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height]) + expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[resolution]) + expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[resolution]) await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) @@ -640,11 +617,12 @@ describe('Test live', function () { const video = await servers[0].videos.get({ id: liveVideoId }) const hlsFiles = video.streamingPlaylists[0].files - expect(video.files).to.have.lengthOf(0) - expect(hlsFiles).to.have.lengthOf(resolutions.length) + const resolutionsWithAudio = [ VideoResolution.H_NOVIDEO, ...resolutions ] - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare - expect(getAllFiles(video).map(f => f.resolution.id).sort()).to.deep.equal(resolutions) + expect(video.files).to.have.lengthOf(0) + expect(hlsFiles).to.have.lengthOf(resolutionsWithAudio.length) + + expect(getAllFiles(video).map(f => f.resolution.id)).to.have.members(resolutionsWithAudio) }) it('Should only keep the original resolution if all resolutions are disabled', async function () { @@ -677,9 +655,9 @@ describe('Test live', function () { const hlsFiles = video.streamingPlaylists[0].files expect(video.files).to.have.lengthOf(0) - expect(hlsFiles).to.have.lengthOf(1) - expect(hlsFiles[0].resolution.id).to.equal(720) + expect(hlsFiles).to.have.lengthOf(2) + expect(hlsFiles.map(f => f.resolution.id)).to.have.members([ VideoResolution.H_720P, VideoResolution.H_NOVIDEO ]) }) after(async function () { diff --git a/packages/tests/src/api/notifications/user-notifications.ts b/packages/tests/src/api/notifications/user-notifications.ts index 07438067a..2f1fe2191 100644 --- a/packages/tests/src/api/notifications/user-notifications.ts +++ b/packages/tests/src/api/notifications/user-notifications.ts @@ -451,14 +451,12 @@ describe('Test user notifications', function () { await waitJobs(servers) await servers[1].live.waitUntilPublished({ videoId: shortUUID }) - const liveDetails = await servers[1].videos.get({ id: shortUUID }) - await stopFfmpeg(ffmpegCommand) await servers[1].live.waitUntilWaiting({ videoId: shortUUID }) await waitJobs(servers) - const video = await findExternalSavedVideo(servers[1], liveDetails) + const video = await findExternalSavedVideo(servers[1], shortUUID) expect(video).to.exist await checkMyVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' }) diff --git a/packages/tests/src/api/object-storage/live.ts b/packages/tests/src/api/object-storage/live.ts index 71aa62357..e9dff519e 100644 --- a/packages/tests/src/api/object-storage/live.ts +++ b/packages/tests/src/api/object-storage/live.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' +import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' -import { HttpStatusCode, LiveVideoCreate, VideoPrivacy } from '@peertube/peertube-models' import { cleanupTests, createMultipleServers, @@ -23,6 +22,7 @@ import { expectStartWith } from '@tests/shared/checks.js' import { testLiveVideoResolutions } from '@tests/shared/live.js' import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js' import { SQLCommand } from '@tests/shared/sql-command.js' +import { expect } from 'chai' async function createLive (server: PeerTubeServer, permanent: boolean) { const attributes: LiveVideoCreate = { @@ -118,7 +118,7 @@ describe('Object storage for lives', function () { let videoUUID: string before(async function () { - await servers[0].config.enableLive({ transcoding: false }) + await servers[0].config.enableLive({ transcoding: false, allowReplay: true }) videoUUID = await createLive(servers[0], false) }) @@ -157,10 +157,10 @@ describe('Object storage for lives', function () { }) describe('With live transcoding', function () { - const resolutions = [ 720, 480, 360, 240, 144 ] + const resolutions = [ VideoResolution.H_720P, VideoResolution.H_240P ] before(async function () { - await servers[0].config.enableLive({ transcoding: true }) + await servers[0].config.enableLive({ transcoding: true, resolutions }) }) describe('Normal replay', function () { @@ -195,7 +195,8 @@ describe('Object storage for lives', function () { await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent) await waitJobs(servers) - await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles: 5, objectStorage }) + const numberOfFiles = resolutions.length + 1 // +1 for the HLS audio file + await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles, objectStorage }) }) it('Should have cleaned up live files from object storage', async function () { @@ -235,10 +236,10 @@ describe('Object storage for lives', function () { await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent) await waitJobs(servers) - const videoLiveDetails = await servers[0].videos.get({ id: videoUUIDPermanent }) - const replay = await findExternalSavedVideo(servers[0], videoLiveDetails) + const replay = await findExternalSavedVideo(servers[0], videoUUIDPermanent) - await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles: 5, objectStorage }) + const numberOfFiles = resolutions.length + 1 // +1 for the HLS audio file + await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles, objectStorage }) }) it('Should have cleaned up live files from object storage', async function () { diff --git a/packages/tests/src/api/object-storage/video-static-file-privacy.ts b/packages/tests/src/api/object-storage/video-static-file-privacy.ts index f5e38de5e..842e6a6f7 100644 --- a/packages/tests/src/api/object-storage/video-static-file-privacy.ts +++ b/packages/tests/src/api/object-storage/video-static-file-privacy.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { getAllFiles, getHLS } from '@peertube/peertube-core-utils' -import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' import { areScalewayObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { cleanupTests, @@ -300,7 +300,7 @@ describe('Object storage for video static file privacy', function () { server, videoUUID: privateVideoUUID, videoFileToken, - resolutions: [ 240, 720 ], + resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ], isLive: false }) }) @@ -491,7 +491,7 @@ describe('Object storage for video static file privacy', function () { server, videoUUID: permanentLiveId, videoFileToken, - resolutions: [ 720 ], + resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ], isLive: true }) @@ -513,8 +513,7 @@ describe('Object storage for video static file privacy', function () { await server.live.waitUntilWaiting({ videoId: permanentLiveId }) await waitJobs([ server ]) - const live = await server.videos.getWithToken({ id: permanentLiveId }) - const replayFromList = await findExternalSavedVideo(server, live) + const replayFromList = await findExternalSavedVideo(server, permanentLiveId) const replay = await server.videos.getWithToken({ id: replayFromList.id }) await checkReplay(replay) diff --git a/packages/tests/src/api/runners/runner-common.ts b/packages/tests/src/api/runners/runner-common.ts index 53ea321d0..ddcaa4652 100644 --- a/packages/tests/src/api/runners/runner-common.ts +++ b/packages/tests/src/api/runners/runner-common.ts @@ -561,7 +561,7 @@ describe('Test runner common actions', function () { const { data } = await server.runnerJobs.list({ count: 50, sort: '-updatedAt' }) const children = data.filter(j => j.parent?.uuid === failedJob.uuid) - expect(children).to.have.lengthOf(9) + expect(children).to.have.lengthOf(5) for (const child of children) { expect(child.parent.uuid).to.equal(failedJob.uuid) @@ -599,7 +599,7 @@ describe('Test runner common actions', function () { { const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) const children = data.filter(j => j.parent?.uuid === jobUUID) - expect(children).to.have.lengthOf(9) + expect(children).to.have.lengthOf(5) for (const child of children) { expect(child.state.id).to.equal(RunnerJobState.PARENT_CANCELLED) diff --git a/packages/tests/src/api/runners/runner-live-transcoding.ts b/packages/tests/src/api/runners/runner-live-transcoding.ts index 20c1e5c2a..2456fa04d 100644 --- a/packages/tests/src/api/runners/runner-live-transcoding.ts +++ b/packages/tests/src/api/runners/runner-live-transcoding.ts @@ -119,13 +119,17 @@ describe('Test runner live transcoding', function () { expect(job.type).to.equal('live-rtmp-hls-transcoding') expect(job.payload.input.rtmpUrl).to.exist - expect(job.payload.output.toTranscode).to.have.lengthOf(5) + expect(job.payload.output.toTranscode).to.have.lengthOf(6) for (const { resolution, fps } of job.payload.output.toTranscode) { - expect([ 720, 480, 360, 240, 144 ]).to.contain(resolution) + expect([ 720, 480, 360, 240, 144, 0 ]).to.contain(resolution) - expect(fps).to.be.above(25) - expect(fps).to.be.below(70) + if (resolution === 0) { + expect(fps).to.equal(0) + } else { + expect(fps).to.be.above(25) + expect(fps).to.be.below(70) + } } }) diff --git a/packages/tests/src/api/runners/runner-socket.ts b/packages/tests/src/api/runners/runner-socket.ts index 726ef084f..0ff44083d 100644 --- a/packages/tests/src/api/runners/runner-socket.ts +++ b/packages/tests/src/api/runners/runner-socket.ts @@ -23,7 +23,7 @@ describe('Test runner socket', function () { await setAccessTokensToServers([ server ]) await setDefaultVideoChannel([ server ]) - await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableTranscoding({ hls: false, webVideo: true }) await server.config.enableRemoteTranscoding() runnerToken = await server.runners.autoRegisterRunner() }) diff --git a/packages/tests/src/api/runners/runner-vod-transcoding.ts b/packages/tests/src/api/runners/runner-vod-transcoding.ts index fe1c8f0b2..869fd7621 100644 --- a/packages/tests/src/api/runners/runner-vod-transcoding.ts +++ b/packages/tests/src/api/runners/runner-vod-transcoding.ts @@ -111,6 +111,8 @@ describe('Test runner VOD transcoding', function () { it('Should cancel a transcoding job', async function () { await servers[0].runnerJobs.cancelAllJobs() + + await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) await waitJobs(servers) @@ -397,16 +399,18 @@ describe('Test runner VOD transcoding', function () { await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken) }) - it('Should have 9 jobs to process', async function () { + it('Should have 5 jobs to process', async function () { const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) - expect(availableJobs).to.have.lengthOf(9) + expect(availableJobs).to.have.lengthOf(5) const webVideoJobs = availableJobs.filter(j => j.type === 'vod-web-video-transcoding') + + // Other HLS resolution jobs needs to web video transcoding to be processed first const hlsJobs = availableJobs.filter(j => j.type === 'vod-hls-transcoding') expect(webVideoJobs).to.have.lengthOf(4) - expect(hlsJobs).to.have.lengthOf(5) + expect(hlsJobs).to.have.lengthOf(1) }) it('Should process all available jobs', async function () { @@ -489,13 +493,13 @@ describe('Test runner VOD transcoding', function () { } }) - it('Should have 7 lower resolutions to transcode', async function () { + it('Should have 4 lower resolutions to transcode', async function () { const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) - expect(availableJobs).to.have.lengthOf(7) + expect(availableJobs).to.have.lengthOf(4) for (const resolution of [ 360, 240, 144 ]) { const jobs = availableJobs.filter(j => j.payload.output.resolution === resolution) - expect(jobs).to.have.lengthOf(2) + expect(jobs).to.have.lengthOf(1) } jobUUID = availableJobs.find(j => j.payload.output.resolution === 480).uuid diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts index 78728893b..b644abe59 100644 --- a/packages/tests/src/api/server/config.ts +++ b/packages/tests/src/api/server/config.ts @@ -83,6 +83,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true expect(data.transcoding.webVideos.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true + expect(data.transcoding.hls.splitAudioAndVideo).to.be.false expect(data.transcoding.originalFile.keep).to.be.false expect(data.live.enabled).to.be.false @@ -95,6 +96,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.live.transcoding.remoteRunners.enabled).to.be.false expect(data.live.transcoding.threads).to.equal(2) expect(data.live.transcoding.profile).to.equal('default') + expect(data.live.transcoding.resolutions['0p']).to.be.false expect(data.live.transcoding.resolutions['144p']).to.be.false expect(data.live.transcoding.resolutions['240p']).to.be.false expect(data.live.transcoding.resolutions['360p']).to.be.false @@ -257,7 +259,8 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { enabled: true }, hls: { - enabled: false + enabled: false, + splitAudioAndVideo: true } }, live: { @@ -277,6 +280,7 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { threads: 4, profile: 'live_profile', resolutions: { + '0p': true, '144p': true, '240p': true, '360p': true, diff --git a/packages/tests/src/api/server/jobs.ts b/packages/tests/src/api/server/jobs.ts index 3d60b1431..8f71759b2 100644 --- a/packages/tests/src/api/server/jobs.ts +++ b/packages/tests/src/api/server/jobs.ts @@ -1,7 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { dateIsValid } from '@tests/shared/checks.js' import { wait } from '@peertube/peertube-core-utils' import { cleanupTests, @@ -11,6 +9,8 @@ import { setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' +import { dateIsValid } from '@tests/shared/checks.js' +import { expect } from 'chai' describe('Test jobs', function () { let servers: PeerTubeServer[] @@ -101,12 +101,13 @@ describe('Test jobs', function () { { const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' }) - // waiting includes waiting-children - expect(body.data).to.have.lengthOf(4) + // root transcoding + expect(body.data).to.have.lengthOf(1) } { - const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'video-transcoding' }) + const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'transcoding-job-builder' }) + // next transcoding jobs expect(body.data).to.have.lengthOf(1) } }) diff --git a/packages/tests/src/api/transcoding/audio-only.ts b/packages/tests/src/api/transcoding/audio-only.ts index 6d0410348..e98cfb6ff 100644 --- a/packages/tests/src/api/transcoding/audio-only.ts +++ b/packages/tests/src/api/transcoding/audio-only.ts @@ -51,52 +51,61 @@ describe('Test audio only video transcoding', function () { await doubleFollow(servers[0], servers[1]) }) - it('Should upload a video and transcode it', async function () { - this.timeout(120000) + for (const concurrency of [ 1, 2 ]) { + describe(`With transcoding concurrency ${concurrency}`, function () { - const { uuid } = await servers[0].videos.upload({ attributes: { name: 'audio only' } }) - videoUUID = uuid + before(async function () { + await servers[0].config.setTranscodingConcurrency(concurrency) + }) - await waitJobs(servers) + it('Should upload a video and transcode it', async function () { + this.timeout(120000) - for (const server of servers) { - const video = await server.videos.get({ id: videoUUID }) - expect(video.streamingPlaylists).to.have.lengthOf(1) + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'audio only' } }) + videoUUID = uuid - for (const files of [ video.files, video.streamingPlaylists[0].files ]) { - expect(files).to.have.lengthOf(3) - expect(files[0].resolution.id).to.equal(720) - expect(files[1].resolution.id).to.equal(240) - expect(files[2].resolution.id).to.equal(0) - } + await waitJobs(servers) - if (server.serverNumber === 1) { - webVideoAudioFileUrl = video.files[2].fileUrl - fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl - } - } - }) + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.streamingPlaylists).to.have.lengthOf(1) - it('0p transcoded video should not have video', async function () { - const paths = [ - servers[0].servers.buildWebVideoFilePath(webVideoAudioFileUrl), - servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl) - ] + for (const files of [ video.files, video.streamingPlaylists[0].files ]) { + expect(files).to.have.lengthOf(3) + expect(files[0].resolution.id).to.equal(720) + expect(files[1].resolution.id).to.equal(240) + expect(files[2].resolution.id).to.equal(0) + } - for (const path of paths) { - const { audioStream } = await getAudioStream(path) - expect(audioStream['codec_name']).to.be.equal('aac') - expect(audioStream['bit_rate']).to.be.at.most(384 * 8000) + if (server.serverNumber === 1) { + webVideoAudioFileUrl = video.files[2].fileUrl + fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl + } + } + }) - const size = await getVideoStreamDimensionsInfo(path) + it('0p transcoded video should not have video', async function () { + const paths = [ + servers[0].servers.buildWebVideoFilePath(webVideoAudioFileUrl), + servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl) + ] - expect(size.height).to.equal(0) - expect(size.width).to.equal(0) - expect(size.isPortraitMode).to.be.false - expect(size.ratio).to.equal(0) - expect(size.resolution).to.equal(0) - } - }) + for (const path of paths) { + const { audioStream } = await getAudioStream(path) + expect(audioStream['codec_name']).to.be.equal('aac') + expect(audioStream['bit_rate']).to.be.at.most(384 * 8000) + + const size = await getVideoStreamDimensionsInfo(path) + + expect(size.height).to.equal(0) + expect(size.width).to.equal(0) + expect(size.isPortraitMode).to.be.false + expect(size.ratio).to.equal(0) + expect(size.resolution).to.equal(0) + } + }) + }) + } after(async function () { await cleanupTests(servers) diff --git a/packages/tests/src/api/transcoding/create-transcoding.ts b/packages/tests/src/api/transcoding/create-transcoding.ts index 7b37c5279..d2de20c7e 100644 --- a/packages/tests/src/api/transcoding/create-transcoding.ts +++ b/packages/tests/src/api/transcoding/create-transcoding.ts @@ -39,7 +39,12 @@ async function checkFilesInObjectStorage (objectStorage: ObjectStorageCommand, v await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) } -function runTests (enableObjectStorage: boolean) { +function runTests (options: { + concurrency: number + enableObjectStorage: boolean +}) { + const { concurrency, enableObjectStorage } = options + let servers: PeerTubeServer[] = [] let videoUUID: string let publishedAt: string @@ -73,6 +78,7 @@ function runTests (enableObjectStorage: boolean) { publishedAt = video.publishedAt as string await servers[0].config.enableTranscoding() + await servers[0].config.setTranscodingConcurrency(concurrency) }) it('Should generate HLS', async function () { @@ -164,7 +170,7 @@ function runTests (enableObjectStorage: boolean) { newConfig: { transcoding: { enabled: true, - resolutions: ConfigCommand.getCustomConfigResolutions(false), + resolutions: ConfigCommand.getConfigResolutions(false), webVideos: { enabled: true @@ -200,7 +206,7 @@ function runTests (enableObjectStorage: boolean) { newConfig: { transcoding: { enabled: true, - resolutions: ConfigCommand.getCustomConfigResolutions(true), + resolutions: ConfigCommand.getConfigResolutions(true), webVideos: { enabled: true @@ -255,13 +261,18 @@ function runTests (enableObjectStorage: boolean) { describe('Test create transcoding jobs from API', function () { - describe('On filesystem', function () { - runTests(false) - }) + for (const concurrency of [ 1, 2 ]) { + describe('With concurrency ' + concurrency, function () { - describe('On object storage', function () { - if (areMockObjectStorageTestsDisabled()) return + describe('On filesystem', function () { + runTests({ concurrency, enableObjectStorage: false }) + }) - runTests(true) - }) + describe('On object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + runTests({ concurrency, enableObjectStorage: true }) + }) + }) + } }) diff --git a/packages/tests/src/api/transcoding/hls.ts b/packages/tests/src/api/transcoding/hls.ts index 2b81e63c7..9bccb548f 100644 --- a/packages/tests/src/api/transcoding/hls.ts +++ b/packages/tests/src/api/transcoding/hls.ts @@ -19,9 +19,25 @@ import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' describe('Test HLS videos', function () { let servers: PeerTubeServer[] = [] - function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) { + function runTestSuite (options: { + hlsOnly: boolean + concurrency: number + objectStorageBaseUrl?: string + }) { + const { hlsOnly, objectStorageBaseUrl, concurrency } = options + const videoUUIDs: string[] = [] + before(async function () { + await servers[0].config.enableTranscoding({ + resolutions: [ 720, 480, 360, 240 ], + hls: true, + webVideo: !hlsOnly + }) + + await servers[0].config.setTranscodingConcurrency(concurrency) + }) + it('Should upload a video and transcode it to HLS', async function () { this.timeout(120000) @@ -112,41 +128,18 @@ describe('Test HLS videos', function () { await doubleFollow(servers[0], servers[1]) }) - describe('With Web Video & HLS enabled', function () { - runTestSuite(false) - }) + for (const concurrency of [ 1, 2 ]) { + describe(`With concurrency ${concurrency}`, function () { - describe('With only HLS enabled', function () { + describe('With Web Video & HLS enabled', function () { + runTestSuite({ hlsOnly: false, concurrency }) + }) - before(async function () { - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - allowAudioFiles: true, - resolutions: { - '144p': false, - '240p': true, - '360p': true, - '480p': true, - '720p': true, - '1080p': true, - '1440p': true, - '2160p': true - }, - hls: { - enabled: true - }, - webVideos: { - enabled: false - } - } - } + describe('With only HLS enabled', function () { + runTestSuite({ hlsOnly: true, concurrency }) }) }) - - runTestSuite(true) - }) + } describe('With object storage enabled', function () { if (areMockObjectStorageTestsDisabled()) return @@ -163,7 +156,11 @@ describe('Test HLS videos', function () { await servers[0].run(configOverride) }) - runTestSuite(true, objectStorage.getMockPlaylistBaseUrl()) + for (const concurrency of [ 1, 2 ]) { + describe(`With concurrency ${concurrency}`, function () { + runTestSuite({ hlsOnly: true, concurrency, objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl() }) + }) + } after(async function () { await objectStorage.cleanupMock() diff --git a/packages/tests/src/api/transcoding/index.ts b/packages/tests/src/api/transcoding/index.ts index c25cd51c3..07486fe5a 100644 --- a/packages/tests/src/api/transcoding/index.ts +++ b/packages/tests/src/api/transcoding/index.ts @@ -1,6 +1,7 @@ export * from './audio-only.js' export * from './create-transcoding.js' export * from './hls.js' +export * from './split-audio-and-video.js' export * from './transcoder.js' export * from './update-while-transcoding.js' export * from './video-studio.js' diff --git a/packages/tests/src/api/transcoding/split-audio-and-video.ts b/packages/tests/src/api/transcoding/split-audio-and-video.ts new file mode 100644 index 000000000..487ae6ade --- /dev/null +++ b/packages/tests/src/api/transcoding/split-audio-and-video.ts @@ -0,0 +1,175 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { join } from 'path' +import { HttpStatusCode } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { DEFAULT_AUDIO_RESOLUTION } from '@peertube/peertube-server/core/initializers/constants.js' +import { checkDirectoryIsEmpty, checkTmpIsEmpty } from '@tests/shared/directories.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' + +describe('Test HLS with audio and video splitted', function () { + let servers: PeerTubeServer[] = [] + + function runTestSuite (options: { + hlsOnly: boolean + concurrency: number + objectStorageBaseUrl?: string + }) { + const { hlsOnly, objectStorageBaseUrl, concurrency } = options + + const videoUUIDs: string[] = [] + + before(async function () { + await servers[0].config.enableTranscoding({ + resolutions: [ 720, 480, 360, 240 ], + hls: true, + splitAudioAndVideo: true, + webVideo: !hlsOnly + }) + + await servers[0].config.setTranscodingConcurrency(concurrency) + }) + + it('Should upload a video and transcode it to HLS', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1', fixture: 'video_short.webm' } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, splittedAudio: true, objectStorageBaseUrl }) + }) + + it('Should upload an audio file and transcode it to HLS', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video audio', fixture: 'sample.ogg' } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ + servers, + videoUUID: uuid, + hlsOnly, + splittedAudio: true, + resolutions: [ DEFAULT_AUDIO_RESOLUTION, 360, 240 ], + objectStorageBaseUrl + }) + }) + + it('Should update the video', async function () { + this.timeout(30000) + + await servers[0].videos.update({ id: videoUUIDs[0], attributes: { name: 'video 1 updated' } }) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: videoUUIDs[0], hlsOnly, splittedAudio: true, objectStorageBaseUrl }) + }) + + it('Should delete videos', async function () { + for (const uuid of videoUUIDs) { + await servers[0].videos.remove({ id: uuid }) + } + + await waitJobs(servers) + + for (const server of servers) { + for (const uuid of videoUUIDs) { + await server.videos.get({ id: uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + } + }) + + it('Should have the playlists/segment deleted from the disk', async function () { + for (const server of servers) { + await checkDirectoryIsEmpty(server, 'web-videos', [ 'private' ]) + await checkDirectoryIsEmpty(server, join('web-videos', 'private')) + + await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls'), [ 'private' ]) + await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls', 'private')) + } + }) + + it('Should have an empty tmp directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + } + + before(async function () { + this.timeout(120000) + + const configOverride = { + transcoding: { + enabled: true, + allow_audio_files: true, + hls: { + enabled: true + } + } + } + servers = await createMultipleServers(2, configOverride) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + for (const concurrency of [ 1, 2 ]) { + describe(`With concurrency ${concurrency}`, function () { + + describe('With Web Video & HLS enabled', function () { + runTestSuite({ hlsOnly: false, concurrency }) + }) + + describe('With only HLS enabled', function () { + runTestSuite({ hlsOnly: true, concurrency }) + }) + }) + } + + describe('With object storage enabled', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + const configOverride = objectStorage.getDefaultMockConfig() + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + await servers[0].run(configOverride) + }) + + for (const concurrency of [ 1, 2 ]) { + describe(`With concurrency ${concurrency}`, function () { + runTestSuite({ hlsOnly: true, concurrency, objectStorageBaseUrl: objectStorage.getMockPlaylistBaseUrl() }) + }) + } + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/transcoding/transcoder.ts b/packages/tests/src/api/transcoding/transcoder.ts index 0af196286..da4f93112 100644 --- a/packages/tests/src/api/transcoding/transcoder.ts +++ b/packages/tests/src/api/transcoding/transcoder.ts @@ -1,10 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { getAllFiles, getMaxTheoreticalBitrate, getMinTheoreticalBitrate, omit } from '@peertube/peertube-core-utils' -import { HttpStatusCode, VideoFileMetadata, VideoState } from '@peertube/peertube-models' -import { canDoQuickTranscode } from '@peertube/peertube-server/core/lib/transcoding/transcoding-quick-transcode.js' -import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { ffprobePromise, getAudioStream, @@ -13,6 +9,8 @@ import { getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg' +import { HttpStatusCode, VideoFileMetadata, VideoState } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { cleanupTests, createMultipleServers, @@ -22,8 +20,10 @@ import { setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' -import { generateVideoWithFramerate, generateHighBitrateVideo } from '@tests/shared/generate.js' +import { canDoQuickTranscode } from '@peertube/peertube-server/core/lib/transcoding/transcoding-quick-transcode.js' +import { generateHighBitrateVideo, generateVideoWithFramerate } from '@tests/shared/generate.js' import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' +import { expect } from 'chai' function updateConfigForTranscoding (server: PeerTubeServer) { return server.config.updateExistingConfig({ @@ -331,25 +331,7 @@ describe('Test video transcoding', function () { function runSuite (mode: 'legacy' | 'resumable') { before(async function () { - await servers[1].config.updateExistingConfig({ - newConfig: { - transcoding: { - hls: { enabled: true }, - webVideos: { enabled: true }, - resolutions: { - '0p': false, - '144p': false, - '240p': false, - '360p': false, - '480p': false, - '720p': false, - '1080p': false, - '1440p': false, - '2160p': false - } - } - } - }) + await servers[1].config.enableTranscoding({ hls: true, webVideo: true, resolutions: [] }) }) it('Should merge an audio file with the preview file', async function () { diff --git a/packages/tests/src/api/transcoding/video-studio.ts b/packages/tests/src/api/transcoding/video-studio.ts index 698a9db30..0a69e3ac1 100644 --- a/packages/tests/src/api/transcoding/video-studio.ts +++ b/packages/tests/src/api/transcoding/video-studio.ts @@ -15,6 +15,7 @@ import { } from '@peertube/peertube-server-commands' import { checkVideoDuration, expectStartWith } from '@tests/shared/checks.js' import { checkPersistentTmpIsEmpty } from '@tests/shared/directories.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' describe('Test video studio', function () { let servers: PeerTubeServer[] = [] @@ -275,16 +276,7 @@ describe('Test video studio', function () { describe('HLS only studio edition', function () { before(async function () { - // Disable Web Videos - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - webVideos: { - enabled: false - } - } - } - }) + await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true }) }) it('Should run a complex task on HLS only video', async function () { @@ -298,6 +290,31 @@ describe('Test video studio', function () { expect(video.files).to.have.lengthOf(0) await checkVideoDuration(server, videoUUID, 9) + + await completeCheckHlsPlaylist({ servers, videoUUID, hlsOnly: true, resolutions: [ 720, 240 ] }) + } + }) + }) + + describe('HLS with splitted audio studio edition', function () { + + before(async function () { + await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true, splitAudioAndVideo: true }) + }) + + it('Should run a complex task on HLS only video', async function () { + this.timeout(240_000) + await renewVideo() + + await createTasks(VideoStudioCommand.getComplexTask()) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.files).to.have.lengthOf(0) + + await checkVideoDuration(server, videoUUID, 9) + + await completeCheckHlsPlaylist({ servers, videoUUID, hlsOnly: true, splittedAudio: true, resolutions: [ 720, 240 ] }) } }) }) diff --git a/packages/tests/src/api/users/user-export.ts b/packages/tests/src/api/users/user-export.ts index 513ef6c80..8e06313b9 100644 --- a/packages/tests/src/api/users/user-export.ts +++ b/packages/tests/src/api/users/user-export.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { wait } from '@peertube/peertube-core-utils' +import { hasAudioStream, hasVideoStream } from '@peertube/peertube-ffmpeg' import { AccountExportJSON, ActivityPubActor, ActivityPubOrderedCollection, @@ -45,6 +46,7 @@ import { parseAPOutbox, parseZIPJSONFile, prepareImportExportTests, + probeZIPFile, regenerateExport } from '@tests/shared/import-export.js' import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' @@ -264,7 +266,7 @@ function runTest (withObjectStorage: boolean) { expect(outbox.id).to.equal('outbox.json') expect(outbox.type).to.equal('OrderedCollection') - // 3 videos and 2 comments + // 4 videos and 2 comments expect(outbox.totalItems).to.equal(6) expect(outbox.orderedItems).to.have.lengthOf(6) @@ -751,6 +753,53 @@ function runTest (withObjectStorage: boolean) { expect(video.attachment).to.have.lengthOf(1) expect(video.attachment[0].url).to.equal('../files/videos/video-files/' + externalVideo.uuid + '.mp4') await checkFileExistsInZIP(zip, video.attachment[0].url, '/activity-pub') + + const probe = await probeZIPFile(zip, video.attachment[0].url, '/activity-pub') + + expect(await hasAudioStream('', probe)).to.be.true + expect(await hasVideoStream('', probe)).to.be.true + } + }) + + it('Should export videos on instance with HLS only and audio separated', async function () { + this.timeout(120000) + + await remoteServer.config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true, keepOriginal: false }) + + const videoName = 'hls and audio separated' + await remoteServer.videos.quickUpload({ name: videoName }) + await waitJobs([ remoteServer ]) + + await regenerateExport({ server: remoteServer, userId: remoteRootId, withVideoFiles: true }) + + const zip = await downloadZIP(remoteServer, remoteRootId) + let videoUUID: string + + { + const json = await parseZIPJSONFile(zip, 'peertube/videos.json') + + expect(json.videos).to.have.lengthOf(2) + const video = json.videos.find(v => v.name === videoName) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + expect(video.streamingPlaylists[0].files).to.have.lengthOf(3) + + videoUUID = video.uuid + } + + { + const outbox = await parseAPOutbox(zip) + const { object: video } = findVideoObjectInOutbox(outbox, videoName) + + expect(video.attachment).to.have.lengthOf(1) + expect(video.attachment[0].url).to.equal('../files/videos/video-files/' + videoUUID + '.mp4') + await checkFileExistsInZIP(zip, video.attachment[0].url, '/activity-pub') + + const probe = await probeZIPFile(zip, video.attachment[0].url, '/activity-pub') + + expect(await hasAudioStream('', probe)).to.be.true + expect(await hasVideoStream('', probe)).to.be.true } }) diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts index b08323b99..59014486e 100644 --- a/packages/tests/src/api/users/user-import.ts +++ b/packages/tests/src/api/users/user-import.ts @@ -10,6 +10,7 @@ import { VideoPlaylistPrivacy, VideoPlaylistType, VideoPrivacy, + VideoResolution, VideoState } from '@peertube/peertube-models' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' @@ -120,518 +121,574 @@ function runTest (withObjectStorage: boolean) { await server.userExports.downloadLatestArchive({ userId: noahId, destination: archivePath }) }) - it('Should import an archive with video files', async function () { - this.timeout(240000) + describe('Import process', function () { - const { userImport } = await remoteServer.userImports.importArchive({ fixture: archivePath, userId: remoteNoahId }) - latestImportId = userImport.id + it('Should import an archive with video files', async function () { + this.timeout(240000) - await waitJobs([ server, remoteServer ]) + const { userImport } = await remoteServer.userImports.importArchive({ fixture: archivePath, userId: remoteNoahId }) + latestImportId = userImport.id + + await waitJobs([ server, remoteServer ]) + }) + + it('Should have a valid import status', async function () { + const userImport = await remoteServer.userImports.getLatestImport({ userId: remoteNoahId, token: remoteNoahToken }) + + expect(userImport.id).to.equal(latestImportId) + expect(userImport.state.id).to.equal(UserImportState.COMPLETED) + expect(userImport.state.label).to.equal('Completed') + }) }) - it('Should have a valid import status', async function () { - const userImport = await remoteServer.userImports.getLatestImport({ userId: remoteNoahId, token: remoteNoahToken }) + describe('Import data', function () { - expect(userImport.id).to.equal(latestImportId) - expect(userImport.state.id).to.equal(UserImportState.COMPLETED) - expect(userImport.state.label).to.equal('Completed') - }) - - it('Should have correctly imported blocklist', async function () { - { - const { data } = await remoteServer.blocklist.listMyAccountBlocklist({ start: 0, count: 5, token: remoteNoahToken }) - - expect(data).to.have.lengthOf(2) - expect(data.find(a => a.blockedAccount.host === server.host && a.blockedAccount.name === 'mouska')).to.exist - expect(data.find(a => a.blockedAccount.host === blockedServer.host && a.blockedAccount.name === 'root')).to.exist - } - - { - const { data } = await remoteServer.blocklist.listMyServerBlocklist({ start: 0, count: 5, token: remoteNoahToken }) - - expect(data).to.have.lengthOf(1) - expect(data.find(a => a.blockedServer.host === blockedServer.host)).to.exist - } - }) - - it('Should have correctly imported account', async function () { - const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) - - expect(me.account.displayName).to.equal('noah') - expect(me.username).to.equal('noah_remote') - expect(me.account.description).to.equal('super noah description') - expect(me.account.avatars).to.have.lengthOf(4) - - for (const avatar of me.account.avatars) { - await testAvatarSize({ url: remoteServer.url, avatar, imageName: `avatar-resized-${avatar.width}x${avatar.width}` }) - } - }) - - it('Should have correctly imported user settings', async function () { - { - const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) - - expect(me.p2pEnabled).to.be.false - - const settings = me.notificationSettings - - expect(settings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL) - expect(settings.myVideoPublished).to.equal(UserNotificationSettingValue.NONE) - expect(settings.commentMention).to.equal(UserNotificationSettingValue.EMAIL) - } - }) - - it('Should have correctly imported channels', async function () { - const { data: channels } = await remoteServer.channels.listByAccount({ token: remoteNoahToken, accountName: 'noah_remote' }) - - // One default + 2 imported - expect(channels).to.have.lengthOf(3) - - await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_remote_channel' }) - - const importedMain = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_channel' }) - expect(importedMain.displayName).to.equal('Main noah channel') - expect(importedMain.avatars).to.have.lengthOf(0) - expect(importedMain.banners).to.have.lengthOf(0) - - const importedSecond = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_second_channel' }) - expect(importedSecond.displayName).to.equal('noah display name') - expect(importedSecond.description).to.equal('noah description') - expect(importedSecond.support).to.equal('noah support') - - for (const banner of importedSecond.banners) { - await testImage(remoteServer.url, `banner-user-import-resized-${banner.width}`, banner.path) - } - - for (const avatar of importedSecond.avatars) { - await testImage(remoteServer.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') - } - - { - // Also check the correct count on origin server - const { data: channels } = await server.channels.listByAccount({ accountName: 'noah_remote@' + remoteServer.host }) - expect(channels).to.have.lengthOf(2) // noah_remote_channel doesn't have videos so it has not been federated - } - }) - - it('Should have correctly imported following', async function () { - const { data } = await remoteServer.subscriptions.list({ token: remoteNoahToken }) - - expect(data).to.have.lengthOf(2) - expect(data.find(f => f.name === 'mouska_channel' && f.host === server.host)).to.exist - expect(data.find(f => f.name === 'root_channel' && f.host === remoteServer.host)).to.exist - }) - - it('Should not have reimported followers (it is not a migration)', async function () { - for (const checkServer of [ server, remoteServer ]) { - const { data } = await checkServer.channels.listFollowers({ channelName: 'noah_channel@' + remoteServer.host }) - - expect(data).to.have.lengthOf(0) - } - }) - - it('Should not have imported comments (it is not a migration)', async function () { - for (const checkServer of [ server, remoteServer ]) { - { - const threads = await checkServer.comments.listThreads({ videoId: noahVideo.uuid }) - expect(threads.total).to.equal(2) - } - - { - const threads = await checkServer.comments.listThreads({ videoId: mouskaVideo.uuid }) - expect(threads.total).to.equal(1) - } - } - }) - - it('Should have correctly imported likes/dislikes', async function () { - { - const { rating } = await remoteServer.users.getMyRating({ videoId: mouskaVideo.uuid, token: remoteNoahToken }) - expect(rating).to.equal('like') - - for (const checkServer of [ server, remoteServer ]) { - const video = await checkServer.videos.get({ id: mouskaVideo.uuid }) - expect(video.likes).to.equal(2) // Old account + new account rates - expect(video.dislikes).to.equal(0) - } - } - - { - const { rating } = await remoteServer.users.getMyRating({ videoId: noahVideo.uuid, token: remoteNoahToken }) - expect(rating).to.equal('like') - } - - { - const { rating } = await remoteServer.users.getMyRating({ videoId: externalVideo.uuid, token: remoteNoahToken }) - expect(rating).to.equal('dislike') - } - }) - - it('Should have correctly imported user video playlists', async function () { - const { data } = await remoteServer.playlists.listByAccount({ handle: 'noah_remote', token: remoteNoahToken }) - - // Should merge the watch later playlists - expect(data).to.have.lengthOf(3) - - { - const watchLater = data.find(p => p.type.id === VideoPlaylistType.WATCH_LATER) - expect(watchLater).to.exist - expect(watchLater.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) - - // Playlists were merged - expect(watchLater.videosLength).to.equal(2) - - const { data: videos } = await remoteServer.playlists.listVideos({ playlistId: watchLater.id, token: remoteNoahToken }) - - expect(videos[0].position).to.equal(1) - // Mouska is muted - expect(videos[0].video).to.not.exist - expect(videos[1].position).to.equal(2) - expect(videos[1].video.uuid).to.equal(noahVideo.uuid) - - // Not federated - await server.playlists.get({ playlistId: watchLater.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - } - - { - const playlist1 = data.find(p => p.displayName === 'noah playlist 1') - expect(playlist1).to.exist - - expect(playlist1.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) - expect(playlist1.videosLength).to.equal(2) // 1 private video could not be imported - - const { data: videos } = await remoteServer.playlists.listVideos({ playlistId: playlist1.id, token: remoteNoahToken }) - expect(videos[0].position).to.equal(1) - expect(videos[0].startTimestamp).to.equal(2) - expect(videos[0].stopTimestamp).to.equal(3) - expect(videos[0].video).to.not.exist // Mouska is blocked - - expect(videos[1].position).to.equal(2) - expect(videos[1].video.uuid).to.equal(noahVideo.uuid) - - // Federated - await server.playlists.get({ playlistId: playlist1.uuid }) - } - - { - const playlist2 = data.find(p => p.displayName === 'noah playlist 2') - expect(playlist2).to.exist - - expect(playlist2.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) - expect(playlist2.videosLength).to.equal(0) - - // Federated - await server.playlists.get({ playlistId: playlist2.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - } - }) - - it('Should have correctly imported user video history', async function () { - const { data } = await remoteServer.history.list({ token: remoteNoahToken }) - - expect(data).to.have.lengthOf(2) - - expect(data[0].userHistory.currentTime).to.equal(2) - expect(data[0].url).to.equal(remoteServer.url + '/videos/watch/' + externalVideo.uuid) - - expect(data[1].userHistory.currentTime).to.equal(4) - expect(data[1].url).to.equal(server.url + '/videos/watch/' + noahVideo.uuid) - }) - - it('Should have correctly imported watched words lists', async function () { - const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' }) - - expect(data).to.have.lengthOf(2) - - expect(data[0].listName).to.equal('allowed-list') - expect(data[0].words).to.have.members([ 'allowed', 'allowed2' ]) - - expect(data[1].listName).to.equal('forbidden-list') - expect(data[1].words).to.have.members([ 'forbidden' ]) - }) - - it('Should have correctly imported auto tag policies', async function () { - const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' }) - - expect(review).to.have.lengthOf(2) - expect(review).to.have.members([ 'external-link', 'forbidden-list' ]) - }) - - it('Should have correctly imported user videos', async function () { - const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken }) - expect(data).to.have.lengthOf(5) - - { - const privateVideo = data.find(v => v.name === 'noah private video') - expect(privateVideo).to.exist - expect(privateVideo.privacy.id).to.equal(VideoPrivacy.PRIVATE) - - // Not federated - await server.videos.get({ id: privateVideo.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - } - - { - const publicVideo = data.find(v => v.name === 'noah public video') - expect(publicVideo).to.exist - expect(publicVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) - - // Federated - await server.videos.get({ id: publicVideo.uuid }) - } - - { - const passwordVideo = data.find(v => v.name === 'noah password video') - expect(passwordVideo).to.exist - expect(passwordVideo.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED) - - const { data: passwords } = await remoteServer.videoPasswords.list({ videoId: passwordVideo.uuid }) - expect(passwords.map(p => p.password).sort()).to.deep.equal([ 'password1', 'password2' ]) - - // Not federated - await server.videos.get({ id: passwordVideo.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - } - - { - const otherVideo = data.find(v => v.name === 'noah public video second channel') - expect(otherVideo).to.exist - - for (const checkServer of [ server, remoteServer ]) { - await completeVideoCheck({ - server: checkServer, - originServer: remoteServer, - videoUUID: otherVideo.uuid, - objectStorageBaseUrl: objectStorage?.getMockWebVideosBaseUrl(), - - attributes: { - name: 'noah public video second channel', - privacy: (VideoPrivacy.PUBLIC), - category: (12), - tags: [ 'tag1', 'tag2' ], - commentsPolicy: VideoCommentPolicy.DISABLED, - downloadEnabled: false, - nsfw: false, - description: ('video description'), - support: ('video support'), - language: 'fr', - licence: 1, - originallyPublishedAt: new Date(0).toISOString(), - account: { - name: 'noah_remote', - host: remoteServer.host - }, - likes: 0, - dislikes: 0, - duration: 5, - channel: { - displayName: 'noah display name', - name: 'noah_second_channel', - description: 'noah description' - }, - fixture: 'video_short.webm', - files: [ - { - resolution: 720, - height: 720, - width: 1280, - size: 61000 - }, - { - resolution: 240, - height: 240, - width: 426, - size: 23000 - } - ], - thumbnailfile: 'custom-thumbnail-from-preview', - previewfile: 'custom-preview' - } - }) - } - - await completeCheckHlsPlaylist({ - hlsOnly: false, - servers: [ remoteServer, server ], - videoUUID: otherVideo.uuid, - objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl(), - resolutions: [ 720, 240 ] - }) - - const source = await remoteServer.videos.getSource({ id: otherVideo.uuid }) - expect(source.filename).to.equal('video_short.webm') - expect(source.inputFilename).to.equal('video_short.webm') - expect(source.fileDownloadUrl).to.not.exist - - expect(source.metadata?.format).to.exist - expect(source.metadata?.streams).to.be.an('array') - } - - { - const liveVideo = data.find(v => v.name === 'noah live video') - expect(liveVideo).to.exist - - await remoteServer.videos.get({ id: liveVideo.uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) - const video = await remoteServer.videos.getWithPassword({ id: liveVideo.uuid, password: 'password1' }) - const live = await remoteServer.live.get({ videoId: liveVideo.uuid, token: remoteNoahToken }) - - expect(video.isLive).to.be.true - expect(live.latencyMode).to.equal(LiveVideoLatencyMode.SMALL_LATENCY) - expect(live.saveReplay).to.be.true - expect(live.permanentLive).to.be.true - expect(live.streamKey).to.exist - expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC) - - expect(video.channel.name).to.equal('noah_second_channel') - expect(video.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED) - - expect(video.duration).to.equal(0) - expect(video.files).to.have.lengthOf(0) - expect(video.streamingPlaylists).to.have.lengthOf(0) - - expect(video.state.id).to.equal(VideoState.WAITING_FOR_LIVE) - } - }) - - it('Should re-import the same file', async function () { - this.timeout(240000) - - const { userImport } = await remoteServer.userImports.importArchive({ fixture: archivePath, userId: remoteNoahId }) - await waitJobs([ remoteServer ]) - latestImportId = userImport.id - }) - - it('Should have the status of this new reimport', async function () { - const userImport = await remoteServer.userImports.getLatestImport({ userId: remoteNoahId, token: remoteNoahToken }) - - expect(userImport.id).to.equal(latestImportId) - expect(userImport.state.id).to.equal(UserImportState.COMPLETED) - expect(userImport.state.label).to.equal('Completed') - }) - - it('Should not have duplicated data', async function () { - // Blocklist - { + it('Should have correctly imported blocklist', async function () { { const { data } = await remoteServer.blocklist.listMyAccountBlocklist({ start: 0, count: 5, token: remoteNoahToken }) + expect(data).to.have.lengthOf(2) + expect(data.find(a => a.blockedAccount.host === server.host && a.blockedAccount.name === 'mouska')).to.exist + expect(data.find(a => a.blockedAccount.host === blockedServer.host && a.blockedAccount.name === 'root')).to.exist } { const { data } = await remoteServer.blocklist.listMyServerBlocklist({ start: 0, count: 5, token: remoteNoahToken }) + expect(data).to.have.lengthOf(1) + expect(data.find(a => a.blockedServer.host === blockedServer.host)).to.exist } - } - - // My avatars - { - const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) - expect(me.account.avatars).to.have.lengthOf(4) - } - - // Channels - { - const { data: channels } = await remoteServer.channels.listByAccount({ token: remoteNoahToken, accountName: 'noah_remote' }) - expect(channels).to.have.lengthOf(3) - } - - // Following - { - const { data } = await remoteServer.subscriptions.list({ token: remoteNoahToken }) - expect(data).to.have.lengthOf(2) - } - - // Likes/dislikes - { - const video = await remoteServer.videos.get({ id: mouskaVideo.uuid }) - expect(video.likes).to.equal(2) - expect(video.dislikes).to.equal(0) - - const { rating } = await remoteServer.users.getMyRating({ videoId: mouskaVideo.uuid, token: remoteNoahToken }) - expect(rating).to.equal('like') - } - - // Playlists - { - const { data } = await remoteServer.playlists.listByAccount({ handle: 'noah_remote', token: remoteNoahToken }) - expect(data).to.have.lengthOf(3) - } - - // Videos - { - const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken }) - expect(data).to.have.lengthOf(5) - } - - // Watched words - { - const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' }) - expect(data).to.have.lengthOf(2) - } - - // Auto tag policies - { - const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' }) - expect(review).to.have.lengthOf(2) - } - }) - - it('Should have received an email on finished import', async function () { - const email = emails.reverse().find(e => { - return e['to'][0]['address'] === 'noah_remote@example.com' && - e['subject'].includes('archive import has finished') }) - expect(email).to.exist - expect(email['text']).to.contain('as considered duplicate: 5') // 5 videos are considered as duplicates - }) + it('Should have correctly imported account', async function () { + const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) - it('Should auto blacklist imported videos if enabled by the administrator', async function () { - this.timeout(240000) + expect(me.account.displayName).to.equal('noah') + expect(me.username).to.equal('noah_remote') + expect(me.account.description).to.equal('super noah description') + expect(me.account.avatars).to.have.lengthOf(4) - await blockedServer.config.enableAutoBlacklist() - - const { token, userId } = await blockedServer.users.generate('blocked_user') - await blockedServer.userImports.importArchive({ fixture: archivePath, userId, token }) - await waitJobs([ blockedServer ]) - - { - const { data } = await blockedServer.videos.listMyVideos({ token }) - expect(data).to.have.lengthOf(5) - - for (const video of data) { - expect(video.blacklisted).to.be.true + for (const avatar of me.account.avatars) { + await testAvatarSize({ url: remoteServer.url, avatar, imageName: `avatar-resized-${avatar.width}x${avatar.width}` }) } - } - }) + }) - it('Should import original file if included in the export', async function () { - this.timeout(120000) + it('Should have correctly imported user settings', async function () { + { + const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) - await server.config.enableMinimumTranscoding({ keepOriginal: true }) - await remoteServer.config.keepSourceFile() + expect(me.p2pEnabled).to.be.false - const archivePath = join(server.getDirectoryPath('tmp'), 'archive2.zip') - const fixture = 'video_short1.webm' + const settings = me.notificationSettings - { - const { token, userId } = await server.users.generate('claire') + expect(settings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL) + expect(settings.myVideoPublished).to.equal(UserNotificationSettingValue.NONE) + expect(settings.commentMention).to.equal(UserNotificationSettingValue.EMAIL) + } + }) - await server.videos.quickUpload({ name: 'claire video', token, fixture }) + it('Should have correctly imported channels', async function () { + const { data: channels } = await remoteServer.channels.listByAccount({ token: remoteNoahToken, accountName: 'noah_remote' }) - await waitJobs([ server ]) + // One default + 2 imported + expect(channels).to.have.lengthOf(3) - await server.userExports.request({ userId, token, withVideoFiles: true }) - await server.userExports.waitForCreation({ userId, token }) + await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_remote_channel' }) - await server.userExports.downloadLatestArchive({ userId, token, destination: archivePath }) - } + const importedMain = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_channel' }) + expect(importedMain.displayName).to.equal('Main noah channel') + expect(importedMain.avatars).to.have.lengthOf(0) + expect(importedMain.banners).to.have.lengthOf(0) - { - const { token, userId } = await remoteServer.users.generate('external_claire') + const importedSecond = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_second_channel' }) + expect(importedSecond.displayName).to.equal('noah display name') + expect(importedSecond.description).to.equal('noah description') + expect(importedSecond.support).to.equal('noah support') - await remoteServer.userImports.importArchive({ fixture: archivePath, userId, token }) - await waitJobs([ remoteServer ]) + for (const banner of importedSecond.banners) { + await testImage(remoteServer.url, `banner-user-import-resized-${banner.width}`, banner.path) + } + + for (const avatar of importedSecond.avatars) { + await testImage(remoteServer.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + } { - const { data } = await remoteServer.videos.listMyVideos({ token }) + // Also check the correct count on origin server + const { data: channels } = await server.channels.listByAccount({ accountName: 'noah_remote@' + remoteServer.host }) + expect(channels).to.have.lengthOf(2) // noah_remote_channel doesn't have videos so it has not been federated + } + }) + + it('Should have correctly imported following', async function () { + const { data } = await remoteServer.subscriptions.list({ token: remoteNoahToken }) + + expect(data).to.have.lengthOf(2) + expect(data.find(f => f.name === 'mouska_channel' && f.host === server.host)).to.exist + expect(data.find(f => f.name === 'root_channel' && f.host === remoteServer.host)).to.exist + }) + + it('Should not have reimported followers (it is not a migration)', async function () { + for (const checkServer of [ server, remoteServer ]) { + const { data } = await checkServer.channels.listFollowers({ channelName: 'noah_channel@' + remoteServer.host }) + + expect(data).to.have.lengthOf(0) + } + }) + + it('Should not have imported comments (it is not a migration)', async function () { + for (const checkServer of [ server, remoteServer ]) { + { + const threads = await checkServer.comments.listThreads({ videoId: noahVideo.uuid }) + expect(threads.total).to.equal(2) + } + + { + const threads = await checkServer.comments.listThreads({ videoId: mouskaVideo.uuid }) + expect(threads.total).to.equal(1) + } + } + }) + + it('Should have correctly imported likes/dislikes', async function () { + { + const { rating } = await remoteServer.users.getMyRating({ videoId: mouskaVideo.uuid, token: remoteNoahToken }) + expect(rating).to.equal('like') + + for (const checkServer of [ server, remoteServer ]) { + const video = await checkServer.videos.get({ id: mouskaVideo.uuid }) + expect(video.likes).to.equal(2) // Old account + new account rates + expect(video.dislikes).to.equal(0) + } + } + + { + const { rating } = await remoteServer.users.getMyRating({ videoId: noahVideo.uuid, token: remoteNoahToken }) + expect(rating).to.equal('like') + } + + { + const { rating } = await remoteServer.users.getMyRating({ videoId: externalVideo.uuid, token: remoteNoahToken }) + expect(rating).to.equal('dislike') + } + }) + + it('Should have correctly imported user video playlists', async function () { + const { data } = await remoteServer.playlists.listByAccount({ handle: 'noah_remote', token: remoteNoahToken }) + + // Should merge the watch later playlists + expect(data).to.have.lengthOf(3) + + { + const watchLater = data.find(p => p.type.id === VideoPlaylistType.WATCH_LATER) + expect(watchLater).to.exist + expect(watchLater.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + + // Playlists were merged + expect(watchLater.videosLength).to.equal(2) + + const { data: videos } = await remoteServer.playlists.listVideos({ playlistId: watchLater.id, token: remoteNoahToken }) + + expect(videos[0].position).to.equal(1) + // Mouska is muted + expect(videos[0].video).to.not.exist + expect(videos[1].position).to.equal(2) + expect(videos[1].video.uuid).to.equal(noahVideo.uuid) + + // Not federated + await server.playlists.get({ playlistId: watchLater.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + { + const playlist1 = data.find(p => p.displayName === 'noah playlist 1') + expect(playlist1).to.exist + + expect(playlist1.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(playlist1.videosLength).to.equal(2) // 1 private video could not be imported + + const { data: videos } = await remoteServer.playlists.listVideos({ playlistId: playlist1.id, token: remoteNoahToken }) + expect(videos[0].position).to.equal(1) + expect(videos[0].startTimestamp).to.equal(2) + expect(videos[0].stopTimestamp).to.equal(3) + expect(videos[0].video).to.not.exist // Mouska is blocked + + expect(videos[1].position).to.equal(2) + expect(videos[1].video.uuid).to.equal(noahVideo.uuid) + + // Federated + await server.playlists.get({ playlistId: playlist1.uuid }) + } + + { + const playlist2 = data.find(p => p.displayName === 'noah playlist 2') + expect(playlist2).to.exist + + expect(playlist2.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + expect(playlist2.videosLength).to.equal(0) + + // Federated + await server.playlists.get({ playlistId: playlist2.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should have correctly imported user video history', async function () { + const { data } = await remoteServer.history.list({ token: remoteNoahToken }) + + expect(data).to.have.lengthOf(2) + + expect(data[0].userHistory.currentTime).to.equal(2) + expect(data[0].url).to.equal(remoteServer.url + '/videos/watch/' + externalVideo.uuid) + + expect(data[1].userHistory.currentTime).to.equal(4) + expect(data[1].url).to.equal(server.url + '/videos/watch/' + noahVideo.uuid) + }) + + it('Should have correctly imported watched words lists', async function () { + const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' }) + + expect(data).to.have.lengthOf(2) + + expect(data[0].listName).to.equal('allowed-list') + expect(data[0].words).to.have.members([ 'allowed', 'allowed2' ]) + + expect(data[1].listName).to.equal('forbidden-list') + expect(data[1].words).to.have.members([ 'forbidden' ]) + }) + + it('Should have correctly imported auto tag policies', async function () { + const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' }) + + expect(review).to.have.lengthOf(2) + expect(review).to.have.members([ 'external-link', 'forbidden-list' ]) + }) + + it('Should have correctly imported user videos', async function () { + const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken }) + expect(data).to.have.lengthOf(5) + + { + const privateVideo = data.find(v => v.name === 'noah private video') + expect(privateVideo).to.exist + expect(privateVideo.privacy.id).to.equal(VideoPrivacy.PRIVATE) + + // Not federated + await server.videos.get({ id: privateVideo.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + { + const publicVideo = data.find(v => v.name === 'noah public video') + expect(publicVideo).to.exist + expect(publicVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) + + // Federated + await server.videos.get({ id: publicVideo.uuid }) + } + + { + const passwordVideo = data.find(v => v.name === 'noah password video') + expect(passwordVideo).to.exist + expect(passwordVideo.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED) + + const { data: passwords } = await remoteServer.videoPasswords.list({ videoId: passwordVideo.uuid }) + expect(passwords.map(p => p.password).sort()).to.deep.equal([ 'password1', 'password2' ]) + + // Not federated + await server.videos.get({ id: passwordVideo.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + { + const otherVideo = data.find(v => v.name === 'noah public video second channel') + expect(otherVideo).to.exist + + for (const checkServer of [ server, remoteServer ]) { + await completeVideoCheck({ + server: checkServer, + originServer: remoteServer, + videoUUID: otherVideo.uuid, + objectStorageBaseUrl: objectStorage?.getMockWebVideosBaseUrl(), + + attributes: { + name: 'noah public video second channel', + privacy: (VideoPrivacy.PUBLIC), + category: (12), + tags: [ 'tag1', 'tag2' ], + commentsPolicy: VideoCommentPolicy.DISABLED, + downloadEnabled: false, + nsfw: false, + description: ('video description'), + support: ('video support'), + language: 'fr', + licence: 1, + originallyPublishedAt: new Date(0).toISOString(), + account: { + name: 'noah_remote', + host: remoteServer.host + }, + likes: 0, + dislikes: 0, + duration: 5, + channel: { + displayName: 'noah display name', + name: 'noah_second_channel', + description: 'noah description' + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + height: 720, + width: 1280, + size: 61000 + }, + { + resolution: 240, + height: 240, + width: 426, + size: 23000 + } + ], + thumbnailfile: 'custom-thumbnail-from-preview', + previewfile: 'custom-preview' + } + }) + } + + await completeCheckHlsPlaylist({ + hlsOnly: false, + servers: [ remoteServer, server ], + videoUUID: otherVideo.uuid, + objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl(), + resolutions: [ 720, 240 ] + }) + + const source = await remoteServer.videos.getSource({ id: otherVideo.uuid }) + expect(source.filename).to.equal('video_short.webm') + expect(source.inputFilename).to.equal('video_short.webm') + expect(source.fileDownloadUrl).to.not.exist + + expect(source.metadata?.format).to.exist + expect(source.metadata?.streams).to.be.an('array') + } + + { + const liveVideo = data.find(v => v.name === 'noah live video') + expect(liveVideo).to.exist + + await remoteServer.videos.get({ id: liveVideo.uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + const video = await remoteServer.videos.getWithPassword({ id: liveVideo.uuid, password: 'password1' }) + const live = await remoteServer.live.get({ videoId: liveVideo.uuid, token: remoteNoahToken }) + + expect(video.isLive).to.be.true + expect(live.latencyMode).to.equal(LiveVideoLatencyMode.SMALL_LATENCY) + expect(live.saveReplay).to.be.true + expect(live.permanentLive).to.be.true + expect(live.streamKey).to.exist + expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC) + + expect(video.channel.name).to.equal('noah_second_channel') + expect(video.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED) + + expect(video.duration).to.equal(0) + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(0) + + expect(video.state.id).to.equal(VideoState.WAITING_FOR_LIVE) + } + }) + }) + + describe('Re-import', function () { + + it('Should re-import the same file', async function () { + this.timeout(240000) + + const { userImport } = await remoteServer.userImports.importArchive({ fixture: archivePath, userId: remoteNoahId }) + await waitJobs([ remoteServer ]) + latestImportId = userImport.id + }) + + it('Should have the status of this new reimport', async function () { + const userImport = await remoteServer.userImports.getLatestImport({ userId: remoteNoahId, token: remoteNoahToken }) + + expect(userImport.id).to.equal(latestImportId) + expect(userImport.state.id).to.equal(UserImportState.COMPLETED) + expect(userImport.state.label).to.equal('Completed') + }) + + it('Should not have duplicated data', async function () { + // Blocklist + { + { + const { data } = await remoteServer.blocklist.listMyAccountBlocklist({ start: 0, count: 5, token: remoteNoahToken }) + expect(data).to.have.lengthOf(2) + } + + { + const { data } = await remoteServer.blocklist.listMyServerBlocklist({ start: 0, count: 5, token: remoteNoahToken }) + expect(data).to.have.lengthOf(1) + } + } + + // My avatars + { + const me = await remoteServer.users.getMyInfo({ token: remoteNoahToken }) + expect(me.account.avatars).to.have.lengthOf(4) + } + + // Channels + { + const { data: channels } = await remoteServer.channels.listByAccount({ token: remoteNoahToken, accountName: 'noah_remote' }) + expect(channels).to.have.lengthOf(3) + } + + // Following + { + const { data } = await remoteServer.subscriptions.list({ token: remoteNoahToken }) + expect(data).to.have.lengthOf(2) + } + + // Likes/dislikes + { + const video = await remoteServer.videos.get({ id: mouskaVideo.uuid }) + expect(video.likes).to.equal(2) + expect(video.dislikes).to.equal(0) + + const { rating } = await remoteServer.users.getMyRating({ videoId: mouskaVideo.uuid, token: remoteNoahToken }) + expect(rating).to.equal('like') + } + + // Playlists + { + const { data } = await remoteServer.playlists.listByAccount({ handle: 'noah_remote', token: remoteNoahToken }) + expect(data).to.have.lengthOf(3) + } + + // Videos + { + const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken }) + expect(data).to.have.lengthOf(5) + } + + // Watched words + { + const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' }) + expect(data).to.have.lengthOf(2) + } + + // Auto tag policies + { + const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' }) + expect(review).to.have.lengthOf(2) + } + }) + }) + + describe('After import', function () { + + it('Should have received an email on finished import', async function () { + const email = emails.reverse().find(e => { + return e['to'][0]['address'] === 'noah_remote@example.com' && + e['subject'].includes('archive import has finished') + }) + + expect(email).to.exist + expect(email['text']).to.contain('as considered duplicate: 5') // 5 videos are considered as duplicates + }) + + it('Should auto blacklist imported videos if enabled by the administrator', async function () { + this.timeout(240000) + + await blockedServer.config.enableAutoBlacklist() + + const { token, userId } = await blockedServer.users.generate('blocked_user') + await blockedServer.userImports.importArchive({ fixture: archivePath, userId, token }) + await waitJobs([ blockedServer ]) + + { + const { data } = await blockedServer.videos.listMyVideos({ token }) + expect(data).to.have.lengthOf(5) + + for (const video of data) { + expect(video.blacklisted).to.be.true + } + } + }) + }) + + describe('Custom video options included in the export', function () { + + async function generateAndExportImport (username: string) { + const archivePath = join(server.getDirectoryPath('tmp'), `archive${username}.zip`) + const fixture = 'video_short1.webm' + + let localToken: string + let remoteToken: string + + { + const { token, userId } = await server.users.generate(username) + localToken = token + + await server.videos.quickUpload({ name: username + ' video', token, fixture }) + await waitJobs([ server ]) + + await server.userExports.request({ userId, token, withVideoFiles: true }) + await server.userExports.waitForCreation({ userId, token }) + + await server.userExports.downloadLatestArchive({ userId, token, destination: archivePath }) + } + + // External server + { + const { token, userId } = await remoteServer.users.generate('external_' + username) + remoteToken = token + + await remoteServer.userImports.importArchive({ fixture: archivePath, userId, token }) + await waitJobs([ remoteServer ]) + } + + return { localToken, remoteToken, fixture } + } + + it('Should import file with audio/video separated', async function () { + this.timeout(120000) + + await server.config.enableMinimumTranscoding({ webVideo: false, hls: true, splitAudioAndVideo: true, keepOriginal: false }) + await remoteServer.config.disableTranscoding() + + const { remoteToken } = await generateAndExportImport('claire1') + + { + const { data } = await remoteServer.videos.listMyVideos({ token: remoteToken }) + expect(data).to.have.lengthOf(1) + + const video = await remoteServer.videos.get({ id: data[0].uuid }) + + expect(video.files).to.have.lengthOf(1) + const file = video.files[0] + + expect(file.resolution.id).to.equal(VideoResolution.H_720P) + + expect(file.hasAudio).to.be.true + expect(file.hasVideo).to.be.true + + const metadata = await remoteServer.videos.getFileMetadata({ url: file.metadataUrl }) + expect(metadata.streams.find(s => s.codec_type === 'video')).to.exist + expect(metadata.streams.find(s => s.codec_type === 'audio')).to.exist + + expect(video.streamingPlaylists).to.have.lengthOf(0) + } + }) + + it('Should import original file if included in the export', async function () { + this.timeout(120000) + + await server.config.enableMinimumTranscoding({ keepOriginal: true }) + await remoteServer.config.enableMinimumTranscoding({ keepOriginal: true }) + + const { remoteToken, fixture } = await generateAndExportImport('claire2') + + { + const { data } = await remoteServer.videos.listMyVideos({ token: remoteToken }) expect(data).to.have.lengthOf(1) const source = await remoteServer.videos.getSource({ id: data[0].id }) @@ -650,7 +707,7 @@ function runTest (withObjectStorage: boolean) { expect(source.resolution.id).to.equal(720) expect(source.size).to.equal(572456) } - } + }) }) after(async function () { diff --git a/packages/tests/src/api/videos/generate-download.ts b/packages/tests/src/api/videos/generate-download.ts new file mode 100644 index 000000000..34c32c00a --- /dev/null +++ b/packages/tests/src/api/videos/generate-download.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { getHLS } from '@peertube/peertube-core-utils' +import { VideoDetails, VideoFile, VideoResolution } from '@peertube/peertube-models' +import { buildSUUID } from '@peertube/peertube-node-utils' +import { + ObjectStorageCommand, + PeerTubeServer, + cleanupTests, + createMultipleServers, + followAll, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkTmpIsEmpty } from '@tests/shared/directories.js' +import { probeResBody } from '@tests/shared/videos.js' +import { expect } from 'chai' +import { FfprobeData } from 'fluent-ffmpeg' + +describe('Test generate download', function () { + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await followAll(servers) + }) + + function runSuite (serverGetter: () => PeerTubeServer, objectStorage?: ObjectStorageCommand) { + const seed = buildSUUID() + + let server: PeerTubeServer + + before(async function () { + this.timeout(120000) + + server = serverGetter() + + if (objectStorage) { + await objectStorage.prepareDefaultMockBuckets() + + await server.kill() + await server.run(objectStorage.getDefaultMockConfig()) + } + + const resolutions = [ VideoResolution.H_NOVIDEO, VideoResolution.H_144P ] + + { + await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions }) + await server.videos.quickUpload({ name: 'common-' + seed }) + await waitJobs(servers) + } + + { + await server.config.enableTranscoding({ webVideo: false, hls: true, splitAudioAndVideo: true, resolutions }) + await server.videos.quickUpload({ name: 'splitted-' + seed }) + await waitJobs(servers) + } + }) + + function getVideoFile (files: VideoFile[]) { + return files.find(f => f.hasVideo === true) + } + + function getAudioFile (files: VideoFile[]) { + return files.find(f => f.hasAudio === true) + } + + function getAudioOnlyFile (files: VideoFile[]) { + return files.find(f => f.hasAudio === true && f.hasVideo === false) + } + + async function getProbe (name: 'common' | 'splitted', filesGetter: (video: VideoDetails) => number[]) { + const video = await servers[0].videos.findFull({ name: name + '-' + seed }) + + const body = await servers[0].videos.generateDownload({ videoId: video.id, videoFileIds: filesGetter(video) }) + + return probeResBody(body) + } + + function checkProbe (probe: FfprobeData, options: { hasVideo: boolean, hasAudio: boolean }) { + expect(probe.streams.some(s => s.codec_type === 'video')).to.equal(options.hasVideo) + expect(probe.streams.some(s => s.codec_type === 'audio')).to.equal(options.hasAudio) + } + + it('Should generate a classic web video file', async function () { + const probe = await getProbe('common', video => [ getVideoFile(video.files).id ]) + + checkProbe(probe, { hasAudio: true, hasVideo: true }) + }) + + it('Should generate a classic HLS file', async function () { + const probe = await getProbe('common', video => [ getVideoFile(getHLS(video).files).id ]) + + checkProbe(probe, { hasAudio: true, hasVideo: true }) + }) + + it('Should generate an audio only web video file', async function () { + const probe = await getProbe('common', video => [ getAudioOnlyFile(video.files).id ]) + + checkProbe(probe, { hasAudio: true, hasVideo: false }) + }) + + it('Should generate an audio only HLS file', async function () { + const probe = await getProbe('common', video => [ getAudioOnlyFile(getHLS(video).files).id ]) + + checkProbe(probe, { hasAudio: true, hasVideo: false }) + }) + + it('Should generate a video only file', async function () { + const probe = await getProbe('splitted', video => [ getVideoFile(getHLS(video).files).id ]) + + checkProbe(probe, { hasAudio: false, hasVideo: true }) + }) + + it('Should merge audio and video files', async function () { + const probe = await getProbe('splitted', video => [ getVideoFile(getHLS(video).files).id, getAudioFile(getHLS(video).files).id ]) + + checkProbe(probe, { hasAudio: true, hasVideo: true }) + }) + + it('Should have cleaned the TMP directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + } + + for (const objectStorage of [ undefined, new ObjectStorageCommand() ]) { + const testName = objectStorage + ? 'On Object Storage' + : 'On filesystem' + + describe(testName, function () { + + describe('Videos on local server', function () { + runSuite(() => servers[0], objectStorage) + }) + + describe('Videos on remote server', function () { + runSuite(() => servers[1], objectStorage) + }) + }) + } + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts index 358fbfaff..aa714c44a 100644 --- a/packages/tests/src/api/videos/index.ts +++ b/packages/tests/src/api/videos/index.ts @@ -1,4 +1,5 @@ import './channel-import-videos.js' +import './generate-download.js' import './multiple-servers.js' import './resumable-upload.js' import './single-server.js' diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts index 6d2f05155..811e705f8 100644 --- a/packages/tests/src/api/videos/multiple-servers.ts +++ b/packages/tests/src/api/videos/multiple-servers.ts @@ -7,7 +7,7 @@ import { PeerTubeServer, cleanupTests, createMultipleServers, - doubleFollow, + followAll, makeGetRequest, setAccessTokensToServers, setDefaultAccountAvatar, @@ -18,6 +18,7 @@ import { dateIsValid, testImageGeneratedByFFmpeg } from '@tests/shared/checks.js import { checkTmpIsEmpty } from '@tests/shared/directories.js' import { checkVideoFilesWereRemoved, completeVideoCheck, saveVideoInServers } from '@tests/shared/videos.js' import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' +import Bluebird from 'bluebird' import { expect } from 'chai' import request from 'supertest' @@ -49,12 +50,7 @@ describe('Test multiple servers', function () { videoChannelId = data[0].id } - // Server 1 and server 2 follow each other - await doubleFollow(servers[0], servers[1]) - // Server 1 and server 3 follow each other - await doubleFollow(servers[0], servers[2]) - // Server 2 and server 3 follow each other - await doubleFollow(servers[1], servers[2]) + await followAll(servers) }) it('Should not have videos for all servers', async function () { @@ -89,7 +85,8 @@ describe('Test multiple servers', function () { // All servers should have this video let publishedAt: string = null - for (const server of servers) { + + await Bluebird.map(servers, async server => { const checkAttributes = { name: 'my super name for server 1', category: 5, @@ -148,7 +145,7 @@ describe('Test multiple servers', function () { expectedStatus: HttpStatusCode.OK_200 }) } - } + }) }) it('Should upload the video on server 2 and propagate on each server', async function () { @@ -180,7 +177,7 @@ describe('Test multiple servers', function () { await waitJobs(servers) // All servers should have this video - for (const server of servers) { + await Bluebird.map(servers, async server => { const checkAttributes = { name: 'my super name for server 2', category: 4, @@ -240,7 +237,7 @@ describe('Test multiple servers', function () { const video = data[1] await completeVideoCheck({ server, originServer: servers[1], videoUUID: video.uuid, attributes: checkAttributes }) - } + }) }) it('Should upload two videos on server 3 and propagate on each server', async function () { @@ -279,7 +276,7 @@ describe('Test multiple servers', function () { await waitJobs(servers) // All servers should have this video - for (const server of servers) { + await Bluebird.map(servers, async server => { const { data } = await server.videos.list() expect(data).to.be.an('array') @@ -363,7 +360,7 @@ describe('Test multiple servers', function () { ] } await completeVideoCheck({ server, originServer: servers[2], videoUUID: video2.uuid, attributes: checkAttributesVideo2 }) - } + }) }) }) @@ -649,7 +646,7 @@ describe('Test multiple servers', function () { it('Should have the video 3 updated on each server', async function () { this.timeout(30000) - for (const server of servers) { + await Bluebird.map(servers, async server => { const { data } = await server.videos.list() const videoUpdated = data.find(video => video.name === 'my super video updated') @@ -693,7 +690,7 @@ describe('Test multiple servers', function () { previewfile: 'custom-preview' } await completeVideoCheck({ server, originServer: servers[2], videoUUID: videoUpdated.uuid, attributes: checkAttributes }) - } + }) }) it('Should be able to remove originallyPublishedAt attribute', async function () { @@ -1044,6 +1041,7 @@ describe('Test multiple servers', function () { }) describe('With minimum parameters', function () { + it('Should upload and propagate the video', async function () { this.timeout(240000) @@ -1062,7 +1060,7 @@ describe('Test multiple servers', function () { await waitJobs(servers) - for (const server of servers) { + await Bluebird.map(servers, async server => { const { data } = await server.videos.list() const video = data.find(v => v.name === 'minimum parameters') @@ -1120,16 +1118,18 @@ describe('Test multiple servers', function () { ] } await completeVideoCheck({ server, originServer: servers[1], videoUUID: video.uuid, attributes: checkAttributes }) - } + }) }) }) describe('TMP directory', function () { + it('Should have an empty tmp directory', async function () { for (const server of servers) { await checkTmpIsEmpty(server) } }) + }) after(async function () { diff --git a/packages/tests/src/api/videos/video-static-file-privacy.ts b/packages/tests/src/api/videos/video-static-file-privacy.ts index 8794aef3d..5577dfdb5 100644 --- a/packages/tests/src/api/videos/video-static-file-privacy.ts +++ b/packages/tests/src/api/videos/video-static-file-privacy.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { getAllFiles, wait } from '@peertube/peertube-core-utils' -import { HttpStatusCode, HttpStatusCodeType, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, HttpStatusCodeType, LiveVideo, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' import { cleanupTests, createSingleServer, @@ -18,6 +17,7 @@ import { import { expectStartWith } from '@tests/shared/checks.js' import { checkVideoFileTokenReinjection } from '@tests/shared/streaming-playlists.js' import { magnetUriDecode, parseTorrentVideo } from '@tests/shared/webtorrent.js' +import { expect } from 'chai' describe('Test video static file privacy', function () { let server: PeerTubeServer @@ -531,7 +531,7 @@ describe('Test video static file privacy', function () { server, videoUUID: permanentLiveId, videoFileToken, - resolutions: [ 720 ], + resolutions: [ VideoResolution.H_720P, VideoResolution.H_240P ], isLive: true }) } @@ -554,8 +554,7 @@ describe('Test video static file privacy', function () { await server.live.waitUntilWaiting({ videoId: permanentLiveId }) await waitJobs([ server ]) - const live = await server.videos.getWithToken({ id: permanentLiveId }) - const replayFromList = await findExternalSavedVideo(server, live) + const replayFromList = await findExternalSavedVideo(server, permanentLiveId) const replay = await server.videos.getWithToken({ id: replayFromList.id }) await checkReplay(replay) diff --git a/packages/tests/src/api/videos/video-transcription.ts b/packages/tests/src/api/videos/video-transcription.ts index f48f2ce32..4d9b55039 100644 --- a/packages/tests/src/api/videos/video-transcription.ts +++ b/packages/tests/src/api/videos/video-transcription.ts @@ -216,6 +216,22 @@ describe('Test video transcription', function () { expect(oldContent).to.equal(newContent) }) + it('Should run transcription with HLS only and audio splitted', async function () { + this.timeout(360000) + + await servers[0].config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true }) + + const uuid = await uploadForTranscription(servers[0], { generateTranscription: false }) + await waitJobs(servers) + await checkLanguage(servers, uuid, null) + + await servers[0].captions.runGenerate({ videoId: uuid }) + await waitJobs(servers) + + await checkAutoCaption(servers, uuid) + await checkLanguage(servers, uuid, 'en') + }) + after(async function () { await cleanupTests(servers) }) diff --git a/packages/tests/src/peertube-runner/live-transcoding.ts b/packages/tests/src/peertube-runner/live-transcoding.ts index 69d44b531..57bba5a2e 100644 --- a/packages/tests/src/peertube-runner/live-transcoding.ts +++ b/packages/tests/src/peertube-runner/live-transcoding.ts @@ -113,8 +113,7 @@ describe('Test Live transcoding in peertube-runner program', function () { expect(session.endDate).to.exist expect(session.saveReplay).to.be.true - const videoLiveDetails = await servers[0].videos.get({ id: video.uuid }) - const replay = await findExternalSavedVideo(servers[0], videoLiveDetails) + const replay = await findExternalSavedVideo(servers[0], video.uuid) for (const server of servers) { const video = await server.videos.get({ id: replay.uuid }) diff --git a/packages/tests/src/peertube-runner/studio-transcoding.ts b/packages/tests/src/peertube-runner/studio-transcoding.ts index 6ce390f10..762ec4ca1 100644 --- a/packages/tests/src/peertube-runner/studio-transcoding.ts +++ b/packages/tests/src/peertube-runner/studio-transcoding.ts @@ -15,6 +15,7 @@ import { import { expectStartWith, checkVideoDuration } from '@tests/shared/checks.js' import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js' import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' describe('Test studio transcoding in peertube-runner program', function () { let servers: PeerTubeServer[] = [] @@ -58,6 +59,33 @@ describe('Test studio transcoding in peertube-runner program', function () { await checkVideoDuration(server, uuid, 9) } }) + + it('Should run a complex task on HLS only video with audio splitted', async function () { + this.timeout(240_000) + + await servers[0].config.enableMinimumTranscoding({ webVideo: false, hls: true, splitAudioAndVideo: true }) + const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4' }) + await waitJobs(servers) + + await servers[0].videoStudio.createEditionTasks({ videoId: uuid, tasks: VideoStudioCommand.getComplexTask() }) + await waitJobs(servers, { runnerJobs: true }) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + expect(video.files).to.have.lengthOf(0) + + await checkVideoDuration(server, uuid, 9) + + await completeCheckHlsPlaylist({ + servers, + videoUUID: uuid, + hlsOnly: true, + splittedAudio: true, + resolutions: [ 720, 240 ], + objectStorageBaseUrl: objectStorage?.getMockPlaylistBaseUrl() + }) + } + }) } before(async function () { diff --git a/packages/tests/src/peertube-runner/video-transcription.ts b/packages/tests/src/peertube-runner/video-transcription.ts index 462bb21eb..fd9ee63ae 100644 --- a/packages/tests/src/peertube-runner/video-transcription.ts +++ b/packages/tests/src/peertube-runner/video-transcription.ts @@ -51,6 +51,20 @@ describe('Test transcription in peertube-runner program', function () { await checkLanguage(servers, uuid, 'en') }) + it('Should run transcription on HLS with audio separated', async function () { + await servers[0].config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true }) + + const uuid = await uploadForTranscription(servers[0], { generateTranscription: false }) + await waitJobs(servers) + await checkLanguage(servers, uuid, null) + + await servers[0].captions.runGenerate({ videoId: uuid }) + await waitJobs(servers, { runnerJobs: true }) + + await checkAutoCaption(servers, uuid) + await checkLanguage(servers, uuid, 'en') + }) + it('Should not run transcription on video without audio stream', async function () { this.timeout(360000) @@ -88,6 +102,7 @@ describe('Test transcription in peertube-runner program', function () { this.timeout(60000) const uuid = await uploadForTranscription(servers[0]) + await waitJobs(servers) await wait(2000) const { data } = await servers[0].runnerJobs.list({ stateOneOf: [ RunnerJobState.PENDING ] }) diff --git a/packages/tests/src/peertube-runner/vod-transcoding.ts b/packages/tests/src/peertube-runner/vod-transcoding.ts index a7397820a..db91a9e0c 100644 --- a/packages/tests/src/peertube-runner/vod-transcoding.ts +++ b/packages/tests/src/peertube-runner/vod-transcoding.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { getAllFiles, wait } from '@peertube/peertube-core-utils' import { VideoPrivacy } from '@peertube/peertube-models' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' @@ -17,17 +16,19 @@ import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js' import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js' import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' import { completeWebVideoFilesCheck } from '@tests/shared/videos.js' +import { expect } from 'chai' describe('Test VOD transcoding in peertube-runner program', function () { let servers: PeerTubeServer[] = [] let peertubeRunner: PeerTubeRunnerProcess - function runSuite (options: { + function runSpecificSuite (options: { webVideoEnabled: boolean hlsEnabled: boolean + splittedAudio?: boolean objectStorage?: ObjectStorageCommand }) { - const { webVideoEnabled, hlsEnabled, objectStorage } = options + const { webVideoEnabled, hlsEnabled, splittedAudio = false, objectStorage } = options const objectStorageBaseUrlWebVideo = objectStorage ? objectStorage.getMockWebVideosBaseUrl() @@ -68,6 +69,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { hlsOnly: !webVideoEnabled, servers, videoUUID: uuid, + splittedAudio, objectStorageBaseUrl: objectStorageBaseUrlHLS, resolutions: [ 720, 480, 360, 240, 144, 0 ] }) @@ -106,6 +108,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { hlsOnly: !webVideoEnabled, servers, videoUUID: uuid, + splittedAudio, objectStorageBaseUrl: objectStorageBaseUrlHLS, resolutions: [ 720, 480, 360, 240, 144, 0 ] }) @@ -144,6 +147,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { hlsOnly: !webVideoEnabled, servers, videoUUID: uuid, + splittedAudio, objectStorageBaseUrl: objectStorageBaseUrlHLS, resolutions: [ 480, 360, 240, 144, 0 ] }) @@ -181,6 +185,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { hlsOnly: !webVideoEnabled, servers: [ servers[0] ], videoUUID: uuid, + splittedAudio, objectStorageBaseUrl: objectStorageBaseUrlHLS, resolutions: [ 720, 480, 360, 240, 144, 0 ] }) @@ -200,7 +205,12 @@ describe('Test VOD transcoding in peertube-runner program', function () { expect(getAllFiles(video)).to.have.lengthOf(1) } - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + await servers[0].config.enableTranscoding({ + hls: hlsEnabled, + webVideo: webVideoEnabled, + splitAudioAndVideo: splittedAudio, + with0p: true + }) await servers[0].videos.runTranscoding({ transcodingType: 'web-video', videoId: uuid }) await waitJobs(servers, { runnerJobs: true }) @@ -228,49 +238,11 @@ describe('Test VOD transcoding in peertube-runner program', function () { hlsOnly: false, servers: [ servers[0] ], videoUUID: uuid, + splittedAudio, objectStorageBaseUrl: objectStorageBaseUrlHLS, resolutions: [ 720, 480, 360, 240, 144, 0 ] }) }) - - it('Should not generate an upper resolution than original file', async function () { - this.timeout(120_000) - - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - hls: { enabled: true }, - webVideos: { enabled: true }, - resolutions: { - '0p': false, - '144p': false, - '240p': true, - '360p': false, - '480p': true, - '720p': false, - '1080p': false, - '1440p': false, - '2160p': false - }, - alwaysTranscodeOriginalResolution: false - } - } - }) - - const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) - await waitJobs(servers, { runnerJobs: true }) - - const video = await servers[0].videos.get({ id: uuid }) - const hlsFiles = video.streamingPlaylists[0].files - - expect(video.files).to.have.lengthOf(2) - expect(hlsFiles).to.have.lengthOf(2) - - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare - const resolutions = getAllFiles(video).map(f => f.resolution.id).sort() - expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ]) - }) } before(async function () { @@ -292,15 +264,14 @@ describe('Test VOD transcoding in peertube-runner program', function () { await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' }) }) - describe('With videos on local filesystem storage', function () { - + function runSuites (objectStorage?: ObjectStorageCommand) { describe('Web video only enabled', function () { before(async function () { await servers[0].config.enableTranscoding({ webVideo: true, hls: false, with0p: true }) }) - runSuite({ webVideoEnabled: true, hlsEnabled: false }) + runSpecificSuite({ webVideoEnabled: true, hlsEnabled: false, objectStorage }) }) describe('HLS videos only enabled', function () { @@ -309,7 +280,25 @@ describe('Test VOD transcoding in peertube-runner program', function () { await servers[0].config.enableTranscoding({ webVideo: false, hls: true, with0p: true }) }) - runSuite({ webVideoEnabled: false, hlsEnabled: true }) + runSpecificSuite({ webVideoEnabled: false, hlsEnabled: true, objectStorage }) + }) + + describe('HLS only with separated audio only enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ webVideo: false, hls: true, splitAudioAndVideo: true, with0p: true }) + }) + + runSpecificSuite({ webVideoEnabled: false, hlsEnabled: true, splittedAudio: true, objectStorage }) + }) + + describe('Web video & HLS with separated audio only enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, splitAudioAndVideo: true, with0p: true }) + }) + + runSpecificSuite({ webVideoEnabled: true, hlsEnabled: true, splittedAudio: true, objectStorage }) }) describe('Web video & HLS enabled', function () { @@ -318,7 +307,54 @@ describe('Test VOD transcoding in peertube-runner program', function () { await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) }) - runSuite({ webVideoEnabled: true, hlsEnabled: true }) + runSpecificSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage }) + }) + } + + describe('With videos on local filesystem storage', function () { + + runSuites() + + describe('Common', function () { + + it('Should not generate an upper resolution than original file', async function () { + this.timeout(120_000) + + await servers[0].config.updateExistingConfig({ + newConfig: { + transcoding: { + enabled: true, + hls: { enabled: true }, + webVideos: { enabled: true }, + resolutions: { + '0p': false, + '144p': false, + '240p': true, + '360p': false, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: false + } + } + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) + await waitJobs(servers, { runnerJobs: true }) + + const video = await servers[0].videos.get({ id: uuid }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(2) + expect(hlsFiles).to.have.lengthOf(2) + + // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + const resolutions = getAllFiles(video).map(f => f.resolution.id).sort() + expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ]) + }) }) }) @@ -338,32 +374,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { await wait(1500) }) - describe('Web video only enabled', function () { - - before(async function () { - await servers[0].config.enableTranscoding({ webVideo: true, hls: false, with0p: true }) - }) - - runSuite({ webVideoEnabled: true, hlsEnabled: false, objectStorage }) - }) - - describe('HLS videos only enabled', function () { - - before(async function () { - await servers[0].config.enableTranscoding({ webVideo: false, hls: true, with0p: true }) - }) - - runSuite({ webVideoEnabled: false, hlsEnabled: true, objectStorage }) - }) - - describe('Web video & HLS enabled', function () { - - before(async function () { - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) - }) - - runSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage }) - }) + runSuites(objectStorage) after(async function () { await objectStorage.cleanupMock() diff --git a/packages/tests/src/plugins/plugin-transcoding.ts b/packages/tests/src/plugins/plugin-transcoding.ts index b36d32289..e23cff6c8 100644 --- a/packages/tests/src/plugins/plugin-transcoding.ts +++ b/packages/tests/src/plugins/plugin-transcoding.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { getAudioStream, getVideoStream, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' -import { VideoPrivacy } from '@peertube/peertube-models' +import { getAudioStream, getVideoStream, getVideoStreamFPS, hasAudioStream, hasVideoStream } from '@peertube/peertube-ffmpeg' +import { VideoPrivacy, VideoResolution } from '@peertube/peertube-models' import { cleanupTests, createSingleServer, @@ -13,6 +12,7 @@ import { testFfmpegStreamError, waitJobs } from '@peertube/peertube-server-commands' +import { expect } from 'chai' async function createLiveWrapper (server: PeerTubeServer) { const liveAttributes = { @@ -84,6 +84,11 @@ describe('Test transcoding plugins', function () { const files = video.files.concat(...video.streamingPlaylists.map(p => p.files)) for (const file of files) { + if (file.resolution.id === VideoResolution.H_NOVIDEO) { + expect(file.fps).to.equal(0) + continue + } + if (type === 'above') { expect(file.fps).to.be.above(fps) } else { @@ -93,13 +98,22 @@ describe('Test transcoding plugins', function () { } async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) { - const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8` - const videoFPS = await getVideoStreamFPS(playlistUrl) + let detectedAudio = false - if (type === 'above') { - expect(videoFPS).to.be.above(fps) - } else { - expect(videoFPS).to.be.below(fps) + for (const playlistName of [ '0.m3u8', '1.m3u8' ]) { + const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/${playlistName}` + const videoFPS = await getVideoStreamFPS(playlistUrl) + + if (!detectedAudio && videoFPS === 0) { + detectedAudio = true + continue + } + + if (type === 'above') { + expect(videoFPS).to.be.above(fps) + } else { + expect(videoFPS).to.be.below(fps) + } } } @@ -267,12 +281,17 @@ describe('Test transcoding plugins', function () { await server.live.waitUntilPublished({ videoId: liveVideoId }) await waitJobs([ server ]) - const playlistUrl = `${server.url}/static/streaming-playlists/hls/${liveVideoId}/0.m3u8` - const audioProbe = await getAudioStream(playlistUrl) - expect(audioProbe.audioStream.codec_name).to.equal('opus') + for (const playlistName of [ '0.m3u8', '1.m3u8' ]) { + const playlistUrl = `${server.url}/static/streaming-playlists/hls/${liveVideoId}/${playlistName}` - const videoProbe = await getVideoStream(playlistUrl) - expect(videoProbe.codec_name).to.equal('h264') + if (await hasAudioStream(playlistUrl)) { + const audioProbe = await getAudioStream(playlistUrl) + expect(audioProbe.audioStream.codec_name).to.equal('opus') + } else if (await hasVideoStream(playlistUrl)) { + const videoProbe = await getVideoStream(playlistUrl) + expect(videoProbe.codec_name).to.equal('h264') + } + } }) }) diff --git a/packages/tests/src/shared/import-export.ts b/packages/tests/src/shared/import-export.ts index 616ceb1f7..e24ac80b4 100644 --- a/packages/tests/src/shared/import-export.ts +++ b/packages/tests/src/shared/import-export.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ +import { ffprobePromise } from '@peertube/peertube-ffmpeg' import { ActivityCreate, ActivityPubOrderedCollection, @@ -12,6 +13,7 @@ import { VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models' +import { getFilenameFromUrl } from '@peertube/peertube-node-utils' import { ConfigCommand, ObjectStorageCommand, @@ -23,12 +25,14 @@ import { waitJobs } from '@peertube/peertube-server-commands' import { expect } from 'chai' +import { ensureDir, remove } from 'fs-extra' +import { writeFile } from 'fs/promises' import JSZip from 'jszip' -import { resolve } from 'path' +import { tmpdir } from 'os' +import { basename, join, resolve } from 'path' +import { testFileExistsOnFSOrNot } from './checks.js' import { MockSmtpServer } from './mock-servers/mock-email.js' import { getAllNotificationsSettings } from './notifications.js' -import { getFilenameFromUrl } from '@peertube/peertube-node-utils' -import { testFileExistsOnFSOrNot } from './checks.js' type ExportOutbox = ActivityPubOrderedCollection> @@ -58,6 +62,24 @@ export async function checkFileExistsInZIP (zip: JSZip, path: string, base = '/' expect(buf.byteLength, `${innerPath} is empty`).to.be.greaterThan(0) } +export async function probeZIPFile (zip: JSZip, path: string, base = '/') { + const innerPath = resolve(base, path).substring(1) // Remove '/' at the beginning of the string + + expect(zip.files[innerPath], `${innerPath} does not exist`).to.exist + + const buf = await zip.file(innerPath).async('arraybuffer') + + const basePath = join(tmpdir(), 'peertube-test') + const videoPath = join(basePath, basename(innerPath)) + await ensureDir(basePath) + await writeFile(videoPath, Buffer.from(buf)) + + const probe = await ffprobePromise(videoPath) + await remove(videoPath) + + return probe +} + // --------------------------------------------------------------------------- export function parseAPOutbox (zip: JSZip) { diff --git a/packages/tests/src/shared/live.ts b/packages/tests/src/shared/live.ts index 2c7f02be0..b9432ac69 100644 --- a/packages/tests/src/shared/live.ts +++ b/packages/tests/src/shared/live.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { LiveVideo, VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models' +import { sha1 } from '@peertube/peertube-node-utils' +import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands' import { expect } from 'chai' import { pathExists } from 'fs-extra/esm' import { readdir } from 'fs/promises' import { join } from 'path' -import { sha1 } from '@peertube/peertube-node-utils' -import { LiveVideo, VideoStreamingPlaylistType } from '@peertube/peertube-models' -import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands' import { SQLCommand } from './sql-command.js' import { checkLiveSegmentHash, checkResolutionsInMasterPlaylist } from './streaming-playlists.js' @@ -25,7 +25,13 @@ async function checkLiveCleanup (options: { if (!await pathExists(hlsPath)) return const files = await readdir(hlsPath) - expect(files).to.have.lengthOf(0) + expect(files.filter(f => f !== 'replay')).to.have.lengthOf(0) + + const replayDir = join(hlsPath, 'replay') + if (await pathExists(replayDir)) { + expect(await readdir(replayDir)).to.have.lengthOf(0) + } + return } @@ -47,6 +53,9 @@ async function testLiveVideoResolutions (options: { resolutions: number[] transcoded: boolean + hasAudio?: boolean + hasVideo?: boolean + objectStorage?: ObjectStorageCommand objectStorageBaseUrl?: string }) { @@ -55,19 +64,34 @@ async function testLiveVideoResolutions (options: { sqlCommand, servers, liveVideoId, - resolutions, transcoded, objectStorage, + hasAudio = true, + hasVideo = true, objectStorageBaseUrl = objectStorage?.getMockPlaylistBaseUrl() } = options + // Live is always audio/video splitted + const splittedAudio = transcoded + + const resolutions = splittedAudio && options.resolutions.length > 1 && !options.resolutions.includes(VideoResolution.H_NOVIDEO) + ? [ VideoResolution.H_NOVIDEO, ...options.resolutions ] + : [ ...options.resolutions ] + + const isAudioOnly = resolutions.every(r => r === VideoResolution.H_NOVIDEO) + for (const server of servers) { const { data } = await server.videos.list() expect(data.find(v => v.uuid === liveVideoId)).to.exist const video = await server.videos.get({ id: liveVideoId }) - expect(video.aspectRatio).to.equal(1.7778) + if (isAudioOnly) { + expect(video.aspectRatio).to.equal(0) + } else { + expect(video.aspectRatio).to.equal(1.7778) + } + expect(video.streamingPlaylists).to.have.lengthOf(1) const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) @@ -79,6 +103,9 @@ async function testLiveVideoResolutions (options: { playlistUrl: hlsPlaylist.playlistUrl, resolutions, transcoded, + splittedAudio, + hasAudio, + hasVideo, withRetry: !!objectStorage }) @@ -123,7 +150,7 @@ async function testLiveVideoResolutions (options: { }) if (originServer.internalServerNumber === server.internalServerNumber) { - const infohash = sha1(`${2 + hlsPlaylist.playlistUrl}+V${i}`) + const infohash = sha1(`2${hlsPlaylist.playlistUrl}+V${i}`) const dbInfohashes = await sqlCommand.getPlaylistInfohash(hlsPlaylist.id) expect(dbInfohashes).to.include(infohash) diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts index 87c318f8a..f73313717 100644 --- a/packages/tests/src/shared/streaming-playlists.ts +++ b/packages/tests/src/shared/streaming-playlists.ts @@ -93,25 +93,68 @@ export async function checkResolutionsInMasterPlaylist (options: { token?: string transcoded?: boolean // default true withRetry?: boolean // default false + splittedAudio?: boolean // default false + hasAudio?: boolean // default true + hasVideo?: boolean // default true }) { - const { server, playlistUrl, resolutions, token, withRetry = false, transcoded = true } = options + const { + server, + playlistUrl, + resolutions, + token, + hasAudio = true, + hasVideo = true, + splittedAudio = false, + withRetry = false, + transcoded = true + } = options const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry }) for (const resolution of resolutions) { - const base = '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + // Audio is always splitted in HLS playlist if needed + if (resolutions.length > 1 && resolution === VideoResolution.H_NOVIDEO) continue - if (resolution === VideoResolution.H_NOVIDEO) { - expect(masterPlaylist).to.match(new RegExp(`${base},CODECS="mp4a.40.2"`)) - } else if (transcoded) { - expect(masterPlaylist).to.match(new RegExp(`${base},(FRAME-RATE=\\d+,)?CODECS="avc1.6400[0-f]{2},mp4a.40.2"`)) - } else { - expect(masterPlaylist).to.match(new RegExp(`${base}`)) + const resolutionStr = hasVideo + ? `,RESOLUTION=\\d+x${resolution}` + : '' + + let regexp = `#EXT-X-STREAM-INF:BANDWIDTH=\\d+${resolutionStr}` + + const videoCodec = hasVideo + ? `avc1.6400[0-f]{2}` + : '' + + const audioCodec = hasAudio + ? 'mp4a.40.2' + : '' + + const codecs = [ videoCodec, audioCodec ].filter(c => !!c).join(',') + + const audioGroup = splittedAudio && hasAudio && hasVideo + ? ',AUDIO="(group_Audio|audio)"' + : '' + + if (transcoded) { + regexp += `,(FRAME-RATE=\\d+,)?CODECS="${codecs}"${audioGroup}` } + + expect(masterPlaylist).to.match(new RegExp(`${regexp}`)) + } + + if (splittedAudio && hasAudio && hasVideo) { + expect(masterPlaylist).to.match( + // eslint-disable-next-line max-len + new RegExp(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="(group_Audio|audio)",NAME="(Audio|audio_0)"(,AUTOSELECT=YES)?,DEFAULT=YES,URI="[^.]*0.m3u8"`) + ) } const playlistsLength = masterPlaylist.split('\n').filter(line => line.startsWith('#EXT-X-STREAM-INF:BANDWIDTH=')) - expect(playlistsLength).to.have.lengthOf(resolutions.length) + const playlistsLengthShouldBe = resolutions.length === 1 + ? 1 + : resolutions.filter(r => r !== VideoResolution.H_NOVIDEO).length + + expect(playlistsLength).to.have.lengthOf(playlistsLengthShouldBe) } export async function completeCheckHlsPlaylist (options: { @@ -119,12 +162,22 @@ export async function completeCheckHlsPlaylist (options: { videoUUID: string hlsOnly: boolean + splittedAudio?: boolean // default false + + hasAudio?: boolean // default true + hasVideo?: boolean // default true + resolutions?: number[] objectStorageBaseUrl?: string }) { - const { videoUUID, hlsOnly, objectStorageBaseUrl } = options + const { videoUUID, hlsOnly, splittedAudio, hasAudio = true, hasVideo = true, objectStorageBaseUrl } = options - const resolutions = options.resolutions ?? [ 240, 360, 480, 720 ] + const hlsResolutions = options.resolutions ?? [ 240, 360, 480, 720 ] + const webVideoResolutions = [ ...hlsResolutions ] + + if (splittedAudio && hasAudio && !hlsResolutions.some(r => r === VideoResolution.H_NOVIDEO)) { + hlsResolutions.push(VideoResolution.H_NOVIDEO) + } for (const server of options.servers) { const videoDetails = await server.videos.getWithToken({ id: videoUUID }) @@ -145,20 +198,25 @@ export async function completeCheckHlsPlaylist (options: { expect(hlsPlaylist).to.not.be.undefined const hlsFiles = hlsPlaylist.files - expect(hlsFiles).to.have.lengthOf(resolutions.length) + expect(hlsFiles).to.have.lengthOf(hlsResolutions.length) if (hlsOnly) expect(videoDetails.files).to.have.lengthOf(0) - else expect(videoDetails.files).to.have.lengthOf(resolutions.length) + else expect(videoDetails.files).to.have.lengthOf(webVideoResolutions.length) // Check JSON files - for (const resolution of resolutions) { + for (const resolution of hlsResolutions) { const file = hlsFiles.find(f => f.resolution.id === resolution) expect(file).to.not.be.undefined if (file.resolution.id === VideoResolution.H_NOVIDEO) { expect(file.resolution.label).to.equal('Audio') + expect(file.hasAudio).to.be.true + expect(file.hasVideo).to.be.false } else { expect(file.resolution.label).to.equal(resolution + 'p') + + expect(file.hasVideo).to.be.true + expect(file.hasAudio).to.equal(hasAudio && !splittedAudio) } if (resolution === 0) { @@ -209,12 +267,20 @@ export async function completeCheckHlsPlaylist (options: { // Check master playlist { - await checkResolutionsInMasterPlaylist({ server, token, playlistUrl: hlsPlaylist.playlistUrl, resolutions }) + await checkResolutionsInMasterPlaylist({ + server, + token, + playlistUrl: hlsPlaylist.playlistUrl, + resolutions: hlsResolutions, + hasAudio, + hasVideo, + splittedAudio + }) const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl, token }) let i = 0 - for (const resolution of resolutions) { + for (const resolution of hlsResolutions) { expect(masterPlaylist).to.contain(`${resolution}.m3u8`) expect(masterPlaylist).to.contain(`${resolution}.m3u8`) @@ -227,7 +293,7 @@ export async function completeCheckHlsPlaylist (options: { // Check resolution playlists { - for (const resolution of resolutions) { + for (const resolution of hlsResolutions) { const file = hlsFiles.find(f => f.resolution.id === resolution) const playlistName = removeFragmentedMP4Ext(basename(file.fileUrl)) + '.m3u8' @@ -257,7 +323,7 @@ export async function completeCheckHlsPlaylist (options: { baseUrlAndPath = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}` } - for (const resolution of resolutions) { + for (const resolution of hlsResolutions) { await checkSegmentHash({ server, token, @@ -312,6 +378,6 @@ export async function checkVideoFileTokenReinjection (options: { } export function extractResolutionPlaylistUrls (masterPath: string, masterContent: string) { - return masterContent.match(/^([^.]+\.m3u8.*)/mg) + return masterContent.match(/[a-z0-9-]+\.m3u8(?:[?a-zA-Z0-9=&-]+)?/mg) .map(filename => join(dirname(masterPath), filename)) } diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts index 1fe43d0c9..f5dbde1c9 100644 --- a/packages/tests/src/shared/videos.ts +++ b/packages/tests/src/shared/videos.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ import { uuidRegex } from '@peertube/peertube-core-utils' +import { ffprobePromise } from '@peertube/peertube-ffmpeg' import { HttpStatusCode, HttpStatusCodeType, @@ -11,7 +12,7 @@ import { VideoPrivacy, VideoResolution } from '@peertube/peertube-models' -import { buildAbsoluteFixturePath, getFileSize, getFilenameFromUrl, getLowercaseExtension } from '@peertube/peertube-node-utils' +import { buildAbsoluteFixturePath, buildUUID, getFileSize, getFilenameFromUrl, getLowercaseExtension } from '@peertube/peertube-node-utils' import { PeerTubeServer, VideoEdit, getRedirectionUrl, makeRawRequest, waitJobs } from '@peertube/peertube-server-commands' import { VIDEO_CATEGORIES, @@ -21,8 +22,9 @@ import { loadLanguages } from '@peertube/peertube-server/core/initializers/constants.js' import { expect } from 'chai' -import { pathExists } from 'fs-extra/esm' -import { readdir } from 'fs/promises' +import { ensureDir, pathExists, remove } from 'fs-extra/esm' +import { readdir, writeFile } from 'fs/promises' +import { tmpdir } from 'os' import { basename, join } from 'path' import { dateIsValid, expectStartWith, testImageGeneratedByFFmpeg } from './checks.js' import { completeCheckHlsPlaylist } from './streaming-playlists.js' @@ -79,7 +81,7 @@ export async function completeWebVideoFilesCheck (options: { expect(file.fileUrl).to.match(new RegExp(`${originServer.url}/static/web-videos/${privatePath}${nameReg}${extension}`)) } - expect(file.fileDownloadUrl).to.match(new RegExp(`${originServer.url}/download/videos/${nameReg}${extension}`)) + expect(file.fileDownloadUrl).to.match(new RegExp(`${originServer.url}/download/web-videos/${nameReg}${extension}`)) } { @@ -106,8 +108,12 @@ export async function completeWebVideoFilesCheck (options: { if (file.resolution.id === VideoResolution.H_NOVIDEO) { expect(file.resolution.label).to.equal('Audio') + expect(file.hasAudio).to.be.true + expect(file.hasVideo).to.be.false } else { expect(file.resolution.label).to.equal(attributeFile.resolution + 'p') + expect(file.hasAudio).to.be.true + expect(file.hasVideo).to.be.true } if (attributeFile.width !== undefined) expect(file.width).to.equal(attributeFile.width) @@ -416,3 +422,16 @@ export async function checkSourceFile (options: { return source } + +export async function probeResBody (body: Buffer) { + const basePath = join(tmpdir(), 'peertube-test', 'ffprobe') + const videoPath = join(basePath, buildUUID()) + + await ensureDir(basePath) + await writeFile(videoPath, body) + + const probe = await ffprobePromise(videoPath) + await remove(videoPath) + + return probe +} diff --git a/packages/tests/src/shared/webtorrent.ts b/packages/tests/src/shared/webtorrent.ts index a50ab464a..ab7282a8e 100644 --- a/packages/tests/src/shared/webtorrent.ts +++ b/packages/tests/src/shared/webtorrent.ts @@ -1,15 +1,24 @@ -import { expect } from 'chai' -import { readFile } from 'fs/promises' -import { basename, join } from 'path' -import type { Instance, Torrent } from 'webtorrent' import { VideoFile } from '@peertube/peertube-models' import { PeerTubeServer } from '@peertube/peertube-server-commands' +import { expect } from 'chai' +import { readFile } from 'fs/promises' import type { Instance as MagnetUriInstance } from 'magnet-uri' - -let webtorrent: Instance +import { basename, join } from 'path' +import type { Torrent } from 'webtorrent' +import WebTorrent from 'webtorrent' export async function checkWebTorrentWorks (magnetUri: string, pathMatch?: RegExp) { - const torrent = await webtorrentAdd(magnetUri, true) + let res: { webtorrent: WebTorrent.Instance, torrent: WebTorrent.Torrent } + + try { + res = await webtorrentAdd(magnetUri) + } catch (err) { + console.error(err) + res = await webtorrentAdd(magnetUri) + } + + const webtorrent = res.webtorrent + const torrent = res.torrent expect(torrent.files).to.be.an('array') expect(torrent.files.length).to.equal(1) @@ -18,6 +27,9 @@ export async function checkWebTorrentWorks (magnetUri: string, pathMatch?: RegEx if (pathMatch) { expect(torrent.files[0].path).match(pathMatch) } + + torrent.destroy() + webtorrent.destroy() } export async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile) { @@ -41,16 +53,26 @@ export async function magnetUriEncode (data: MagnetUriInstance) { // Private // --------------------------------------------------------------------------- -async function webtorrentAdd (torrentId: string, refreshWebTorrent = false) { +async function webtorrentAdd (torrentId: string) { const WebTorrent = (await import('webtorrent')).default - if (webtorrent && refreshWebTorrent) webtorrent.destroy() - if (!webtorrent || refreshWebTorrent) webtorrent = new WebTorrent() + const webtorrent = new WebTorrent() webtorrent.on('error', err => console.error('Error in webtorrent', err)) - return new Promise(res => { - const torrent = webtorrent.add(torrentId, res) + return new Promise<{ torrent: Torrent, webtorrent: typeof webtorrent }>((res, rej) => { + const timeout = setTimeout(() => { + torrent.destroy() + webtorrent.destroy() + + rej(new Error('Timeout to download WebTorrent file ' + torrentId)) + }, 5000) + + const torrent = webtorrent.add(torrentId, t => { + clearTimeout(timeout) + + return res({ torrent: t, webtorrent }) + }) torrent.on('error', err => console.error('Error in webtorrent torrent', err)) torrent.on('warning', warn => { diff --git a/scripts/build/server.sh b/scripts/build/server.sh index 88dec1e0f..a87138fff 100755 --- a/scripts/build/server.sh +++ b/scripts/build/server.sh @@ -2,7 +2,9 @@ set -eu -rm -rf ./dist ./packages/*/dist +if [ -z ${1+x} ] || [ "$1" != "--incremental" ]; then + rm -rf ./dist ./packages/*/dist +fi npm run tsc -- -b --verbose server/tsconfig.json npm run resolve-tspaths:server diff --git a/scripts/dev/embed.sh b/scripts/dev/embed.sh index f8833da8a..845036eff 100755 --- a/scripts/dev/embed.sh +++ b/scripts/dev/embed.sh @@ -2,8 +2,8 @@ set -eu -npm run build:server +npm run build:server -- --incremental npm run concurrently -- -k \ - "cd client && ./node_modules/.bin/vite -c ./src/standalone/videos/vite.config.mjs build -w --mode=development" \ + "cd client && ./node_modules/.bin/vite -c ./src/standalone/videos/vite.config.mjs dev" \ "NODE_ENV=dev npm start" diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 4def10a8e..143763b54 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts @@ -82,7 +82,8 @@ const playerKeys = { 'Disable subtitles': 'Disable subtitles', 'Enable {1} subtitle': 'Enable {1} subtitle', '{1} (auto-generated)': '{1} (auto-generated)', - 'Go back': 'Go back' + 'Go back': 'Go back', + 'Audio only': 'Audio only' } Object.assign(playerKeys, videojs) diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts index 7937104e6..fb7cfc75f 100644 --- a/server/core/controllers/api/config.ts +++ b/server/core/controllers/api/config.ts @@ -347,7 +347,8 @@ function customConfig (): CustomConfig { enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED }, hls: { - enabled: CONFIG.TRANSCODING.HLS.ENABLED + enabled: CONFIG.TRANSCODING.HLS.ENABLED, + splitAudioAndVideo: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO } }, live: { @@ -367,6 +368,7 @@ function customConfig (): CustomConfig { threads: CONFIG.LIVE.TRANSCODING.THREADS, profile: CONFIG.LIVE.TRANSCODING.PROFILE, resolutions: { + '0p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['0p'], '144p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['144p'], '240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'], '360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'], diff --git a/server/core/controllers/api/runners/jobs-files.ts b/server/core/controllers/api/runners/jobs-files.ts index a27128d00..ab75fb811 100644 --- a/server/core/controllers/api/runners/jobs-files.ts +++ b/server/core/controllers/api/runners/jobs-files.ts @@ -1,4 +1,4 @@ -import express from 'express' +import { FileStorage, RunnerJobState, VideoFileStream } from '@peertube/peertube-models' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage/index.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' @@ -9,12 +9,20 @@ import { runnerJobGetVideoStudioTaskFileValidator, runnerJobGetVideoTranscodingFileValidator } from '@server/middlewares/validators/runners/job-files.js' -import { RunnerJobState, FileStorage } from '@peertube/peertube-models' +import { MVideoFileStreamingPlaylistVideo, MVideoFileVideo, MVideoFullLight } from '@server/types/models/index.js' +import express from 'express' const lTags = loggerTagsFactory('api', 'runner') const runnerJobFilesRouter = express.Router() +runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/max-quality/audio', + apiRateLimiter, + asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])), + asyncMiddleware(runnerJobGetVideoTranscodingFileValidator), + asyncMiddleware(getMaxQualitySeparatedAudioFile) +) + runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/max-quality', apiRateLimiter, asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])), @@ -45,6 +53,21 @@ export { // --------------------------------------------------------------------------- +async function getMaxQualitySeparatedAudioFile (req: express.Request, res: express.Response) { + const runnerJob = res.locals.runnerJob + const runner = runnerJob.Runner + const video = res.locals.videoAll + + logger.info( + 'Get max quality separated audio file of video %s of job %s for runner %s', video.uuid, runnerJob.uuid, runner.name, + lTags(runner.name, runnerJob.id, runnerJob.type) + ) + + const file = video.getMaxQualityFile(VideoFileStream.AUDIO) || video.getMaxQualityFile(VideoFileStream.VIDEO) + + return serveVideoFile({ video, file, req, res }) +} + async function getMaxQualityVideoFile (req: express.Request, res: express.Response) { const runnerJob = res.locals.runnerJob const runner = runnerJob.Runner @@ -55,7 +78,18 @@ async function getMaxQualityVideoFile (req: express.Request, res: express.Respon lTags(runner.name, runnerJob.id, runnerJob.type) ) - const file = video.getMaxQualityFile() + const file = video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO) + + return serveVideoFile({ video, file, req, res }) +} + +async function serveVideoFile (options: { + video: MVideoFullLight + file: MVideoFileVideo | MVideoFileStreamingPlaylistVideo + req: express.Request + res: express.Response +}) { + const { video, file, req, res } = options if (file.storage === FileStorage.OBJECT_STORAGE) { if (file.isHLS()) { @@ -82,6 +116,8 @@ async function getMaxQualityVideoFile (req: express.Request, res: express.Respon }) } +// --------------------------------------------------------------------------- + function getMaxQualityVideoPreview (req: express.Request, res: express.Response) { const runnerJob = res.locals.runnerJob const runner = runnerJob.Runner diff --git a/server/core/controllers/api/videos/files.ts b/server/core/controllers/api/videos/files.ts index c62c85c54..39efd024a 100644 --- a/server/core/controllers/api/videos/files.ts +++ b/server/core/controllers/api/videos/files.ts @@ -2,7 +2,7 @@ import express from 'express' import validator from 'validator' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js' -import { updatePlaylistAfterFileChange } from '@server/lib/hls.js' +import { updateM3U8AndShaPlaylist } from '@server/lib/hls.js' import { removeAllWebVideoFiles, removeHLSFile, removeHLSPlaylist, removeWebVideoFile } from '@server/lib/video-file.js' import { VideoFileModel } from '@server/models/video/video-file.js' import { HttpStatusCode, UserRight } from '@peertube/peertube-models' @@ -89,7 +89,7 @@ async function removeHLSFileController (req: express.Request, res: express.Respo logger.info('Deleting HLS file %d of %s.', videoFileId, video.url, lTags(video.uuid)) const playlist = await removeHLSFile(video, videoFileId) - if (playlist) await updatePlaylistAfterFileChange(video, playlist) + if (playlist) await updateM3U8AndShaPlaylist(video, playlist) await federateVideoIfNeeded(video, false, undefined) diff --git a/server/core/controllers/api/videos/source.ts b/server/core/controllers/api/videos/source.ts index 591119ac0..66480949a 100644 --- a/server/core/controllers/api/videos/source.ts +++ b/server/core/controllers/api/videos/source.ts @@ -146,7 +146,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe) await video.VideoChannel.setAsUpdated() - await addVideoJobsAfterUpload(video, video.getMaxQualityFile()) + await addVideoJobsAfterUpload(video, videoFile.withVideoOrPlaylist(video)) logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid)) diff --git a/server/core/controllers/api/videos/transcoding.ts b/server/core/controllers/api/videos/transcoding.ts index 5a5eb6dd1..b533b80ca 100644 --- a/server/core/controllers/api/videos/transcoding.ts +++ b/server/core/controllers/api/videos/transcoding.ts @@ -1,10 +1,10 @@ -import express from 'express' +import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@peertube/peertube-models' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { createTranscodingJobs } from '@server/lib/transcoding/create-transcoding-job.js' import { computeResolutionsToTranscode } from '@server/lib/transcoding/transcoding-resolutions.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' -import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@peertube/peertube-models' +import express from 'express' import { asyncMiddleware, authenticate, createTranscodingValidator, ensureUserHasRight } from '../../../middlewares/index.js' const lTags = loggerTagsFactory('api', 'video') @@ -33,7 +33,8 @@ async function createTranscoding (req: express.Request, res: express.Response) { await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingTranscode') - const { resolution: maxResolution, hasAudio } = await video.probeMaxQualityFile() + const maxResolution = video.getMaxResolution() + const hasAudio = video.hasAudio() const resolutions = await Hooks.wrapObject( computeResolutionsToTranscode({ input: maxResolution, type: 'vod', includeInput: true, strictLower: false, hasAudio }), diff --git a/server/core/controllers/download.ts b/server/core/controllers/download.ts index 92f5c6e08..f2a6bda99 100644 --- a/server/core/controllers/download.ts +++ b/server/core/controllers/download.ts @@ -1,6 +1,8 @@ -import { forceNumber } from '@peertube/peertube-core-utils' +import { forceNumber, maxBy } from '@peertube/peertube-core-utils' import { FileStorage, HttpStatusCode, VideoStreamingPlaylistType } from '@peertube/peertube-models' -import { logger } from '@server/helpers/logger.js' +import { exists } from '@server/helpers/custom-validators/misc.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { CONFIG } from '@server/initializers/config.js' import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache/index.js' import { generateHLSFilePresignedUrl, @@ -10,6 +12,7 @@ import { } from '@server/lib/object-storage/index.js' import { getFSUserExportFilePath } from '@server/lib/paths.js' import { Hooks } from '@server/lib/plugins/hooks.js' +import { muxToMergeVideoFiles } from '@server/lib/video-file.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { MStreamingPlaylist, @@ -22,45 +25,67 @@ import { import { MVideoSource } from '@server/types/models/video/video-source.js' import cors from 'cors' import express from 'express' -import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants.js' +import { DOWNLOAD_PATHS } from '../initializers/constants.js' import { - asyncMiddleware, optionalAuthenticate, + asyncMiddleware, buildRateLimiter, optionalAuthenticate, originalVideoFileDownloadValidator, userExportDownloadValidator, - videosDownloadValidator + videosDownloadValidator, + videosGenerateDownloadValidator } from '../middlewares/index.js' +const lTags = loggerTagsFactory('download') + const downloadRouter = express.Router() downloadRouter.use(cors()) downloadRouter.use( - STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename', + DOWNLOAD_PATHS.TORRENTS + ':filename', asyncMiddleware(downloadTorrent) ) +// --------------------------------------------------------------------------- + downloadRouter.use( - STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', + DOWNLOAD_PATHS.WEB_VIDEOS + ':id-:resolution([0-9]+).:extension', optionalAuthenticate, asyncMiddleware(videosDownloadValidator), asyncMiddleware(downloadWebVideoFile) ) downloadRouter.use( - STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension', + DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension', optionalAuthenticate, asyncMiddleware(videosDownloadValidator), asyncMiddleware(downloadHLSVideoFile) ) +const downloadGenerateRateLimiter = buildRateLimiter({ + windowMs: CONFIG.RATES_LIMIT.DOWNLOAD_GENERATE_VIDEO.WINDOW_MS, + max: CONFIG.RATES_LIMIT.DOWNLOAD_GENERATE_VIDEO.MAX, + skipFailedRequests: true +}) + downloadRouter.use( - STATIC_DOWNLOAD_PATHS.USER_EXPORTS + ':filename', + DOWNLOAD_PATHS.GENERATE_VIDEO + ':id', + downloadGenerateRateLimiter, + optionalAuthenticate, + asyncMiddleware(videosDownloadValidator), + videosGenerateDownloadValidator, + asyncMiddleware(downloadGeneratedVideoFile) +) + +// --------------------------------------------------------------------------- + +downloadRouter.use( + DOWNLOAD_PATHS.USER_EXPORTS + ':filename', asyncMiddleware(userExportDownloadValidator), // Include JWT token authentication asyncMiddleware(downloadUserExport) ) downloadRouter.use( - STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename', + DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename', optionalAuthenticate, asyncMiddleware(originalVideoFileDownloadValidator), asyncMiddleware(downloadOriginalFile) @@ -101,10 +126,12 @@ async function downloadTorrent (req: express.Request, res: express.Response) { return res.download(result.path, result.downloadName) } +// --------------------------------------------------------------------------- + async function downloadWebVideoFile (req: express.Request, res: express.Response) { const video = res.locals.videoAll - const videoFile = getVideoFile(req, video.VideoFiles) + const videoFile = getVideoFileFromReq(req, video.VideoFiles) if (!videoFile) { return res.fail({ status: HttpStatusCode.NOT_FOUND_404, @@ -127,9 +154,7 @@ async function downloadWebVideoFile (req: express.Request, res: express.Response if (!checkAllowResult(res, allowParameters, allowedResult)) return - // Express uses basename on filename parameter - const videoName = video.name.replace(/[/\\]/g, '_') - const downloadFilename = `${videoName}-${videoFile.resolution}p${videoFile.extname}` + const downloadFilename = buildDownloadFilename({ video, resolution: videoFile.resolution, extname: videoFile.extname }) if (videoFile.storage === FileStorage.OBJECT_STORAGE) { return redirectVideoDownloadToObjectStorage({ res, video, file: videoFile, downloadFilename }) @@ -145,7 +170,7 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response const streamingPlaylist = getHLSPlaylist(video) if (!streamingPlaylist) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) - const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles) + const videoFile = getVideoFileFromReq(req, streamingPlaylist.VideoFiles) if (!videoFile) { return res.fail({ status: HttpStatusCode.NOT_FOUND_404, @@ -169,8 +194,7 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response if (!checkAllowResult(res, allowParameters, allowedResult)) return - const videoName = video.name.replace(/\//g, '_') - const downloadFilename = `${videoName}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}` + const downloadFilename = buildDownloadFilename({ video, streamingPlaylist, resolution: videoFile.resolution, extname: videoFile.extname }) if (videoFile.storage === FileStorage.OBJECT_STORAGE) { return redirectVideoDownloadToObjectStorage({ res, video, streamingPlaylist, file: videoFile, downloadFilename }) @@ -181,6 +205,53 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response }) } +// --------------------------------------------------------------------------- + +async function downloadGeneratedVideoFile (req: express.Request, res: express.Response) { + const video = res.locals.videoAll + const filesToSelect = req.query.videoFileIds + + const videoFiles = video.getAllFiles() + .filter(f => filesToSelect.includes(f.id)) + + if (videoFiles.length === 0) { + return res.fail({ + status: HttpStatusCode.NOT_FOUND_404, + message: `No files found (${filesToSelect.join(', ')}) to download video ${video.url}` + }) + } + + if (videoFiles.filter(f => f.hasVideo()).length > 1 || videoFiles.filter(f => f.hasAudio()).length > 1) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + // In theory we could, but ffmpeg-fluent doesn't support multiple input streams so prefer to reject this specific use case + message: `Cannot generate a container with multiple video/audio files. PeerTube supports a maximum of 1 audio and 1 video file` + }) + } + + const allowParameters = { + req, + res, + video, + videoFiles + } + + const allowedResult = await Hooks.wrapFun( + isGeneratedVideoDownloadAllowed, + allowParameters, + 'filter:api.download.generated-video.allowed.result' + ) + + if (!checkAllowResult(res, allowParameters, allowedResult)) return + + const downloadFilename = buildDownloadFilename({ video, extname: maxBy(videoFiles, 'resolution').extname }) + res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`) + + await muxToMergeVideoFiles({ video, videoFiles, output: res }) +} + +// --------------------------------------------------------------------------- + function downloadUserExport (req: express.Request, res: express.Response) { const userExport = res.locals.userExport @@ -209,7 +280,7 @@ function downloadOriginalFile (req: express.Request, res: express.Response) { // --------------------------------------------------------------------------- -function getVideoFile (req: express.Request, files: MVideoFile[]) { +function getVideoFileFromReq (req: express.Request, files: MVideoFile[]) { const resolution = forceNumber(req.params.resolution) return files.find(f => f.resolution === resolution) } @@ -240,9 +311,18 @@ function isVideoDownloadAllowed (_object: { return { allowed: true } } +function isGeneratedVideoDownloadAllowed (_object: { + video: MVideo + videoFiles: MVideoFile[] +}): AllowedResult { + return { allowed: true } +} + +// --------------------------------------------------------------------------- + function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) { if (!result || result.allowed !== true) { - logger.info('Download is not allowed.', { result, allowParameters }) + logger.info('Download is not allowed.', { result, allowParameters, ...lTags() }) res.fail({ status: HttpStatusCode.FORBIDDEN_403, @@ -267,7 +347,7 @@ async function redirectVideoDownloadToObjectStorage (options: { ? await generateHLSFilePresignedUrl({ streamingPlaylist, file, downloadFilename }) : await generateWebVideoPresignedUrl({ file, downloadFilename }) - logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid) + logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid, lTags()) return res.redirect(url) } @@ -281,7 +361,7 @@ async function redirectUserExportToObjectStorage (options: { const url = await generateUserExportPresignedUrl({ userExport, downloadFilename }) - logger.debug('Generating pre-signed URL %s for user export %s', url, userExport.filename) + logger.debug('Generating pre-signed URL %s for user export %s', url, userExport.filename, lTags()) return res.redirect(url) } @@ -295,7 +375,29 @@ async function redirectOriginalFileToObjectStorage (options: { const url = await generateOriginalFilePresignedUrl({ videoSource, downloadFilename }) - logger.debug('Generating pre-signed URL %s for original video file %s', url, videoSource.keptOriginalFilename) + logger.debug('Generating pre-signed URL %s for original video file %s', url, videoSource.keptOriginalFilename, lTags()) return res.redirect(url) } + +function buildDownloadFilename (options: { + video: MVideo + streamingPlaylist?: MStreamingPlaylist + resolution?: number + extname: string +}) { + const { video, resolution, extname, streamingPlaylist } = options + + // Express uses basename on filename parameter + const videoName = video.name.replace(/[/\\]/g, '_') + + const suffixStr = streamingPlaylist + ? `-${streamingPlaylist.getStringType()}` + : '' + + const resolutionStr = exists(resolution) + ? `-${resolution}p` + : '' + + return videoName + resolutionStr + suffixStr + extname +} diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts index 3325280bf..46ae4429a 100644 --- a/server/core/helpers/activity-pub-utils.ts +++ b/server/core/helpers/activity-pub-utils.ts @@ -178,7 +178,10 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string comments: { '@id': 'as:comments', '@type': '@id' - } + }, + + PropertyValue: 'sc:PropertyValue', + value: 'sc:value' }), Playlist: buildContext({ diff --git a/server/core/helpers/custom-validators/misc.ts b/server/core/helpers/custom-validators/misc.ts index 0be9ce2a3..b77cb5c92 100644 --- a/server/core/helpers/custom-validators/misc.ts +++ b/server/core/helpers/custom-validators/misc.ts @@ -4,18 +4,18 @@ import { sep } from 'path' import validator from 'validator' import { isShortUUID, shortToUUID } from '@peertube/peertube-node-utils' -function exists (value: any) { +export function exists (value: any) { return value !== undefined && value !== null } -function isSafePath (p: string) { +export function isSafePath (p: string) { return exists(p) && (p + '').split(sep).every(part => { return [ '..' ].includes(part) === false }) } -function isSafeFilename (filename: string, extension?: string) { +export function isSafeFilename (filename: string, extension?: string) { const regex = extension ? new RegExp(`^[a-z0-9-]+\\.${extension}$`) : new RegExp(`^[a-z0-9-]+\\.[a-z0-9]{1,8}$`) @@ -23,57 +23,68 @@ function isSafeFilename (filename: string, extension?: string) { return typeof filename === 'string' && !!filename.match(regex) } -function isSafePeerTubeFilenameWithoutExtension (filename: string) { +export function isSafePeerTubeFilenameWithoutExtension (filename: string) { return filename.match(/^[a-z0-9-]+$/) } -function isArray (value: any): value is any[] { +// --------------------------------------------------------------------------- + +export function isArray (value: any): value is any[] { return Array.isArray(value) } -function isNotEmptyIntArray (value: any) { +export function isNotEmptyIntArray (value: any) { return Array.isArray(value) && value.every(v => validator.default.isInt('' + v)) && value.length !== 0 } -function isNotEmptyStringArray (value: any) { +export function isNotEmptyStringArray (value: any) { return Array.isArray(value) && value.every(v => typeof v === 'string' && v.length !== 0) && value.length !== 0 } -function isArrayOf (value: any, validator: (value: any) => boolean) { +export function hasArrayLength (value: unknown[], options: { min?: number, max?: number }) { + if (options.min !== undefined && value.length < options.min) return false + if (options.max !== undefined && value.length > options.max) return false + + return true +} + +export function isArrayOf (value: any, validator: (value: any) => boolean) { return isArray(value) && value.every(v => validator(v)) } -function isDateValid (value: string) { +// --------------------------------------------------------------------------- + +export function isDateValid (value: string) { return exists(value) && validator.default.isISO8601(value) } -function isIdValid (value: string) { +export function isIdValid (value: string) { return exists(value) && validator.default.isInt('' + value) } -function isUUIDValid (value: string) { +export function isUUIDValid (value: string) { return exists(value) && validator.default.isUUID('' + value, 4) } -function areUUIDsValid (values: string[]) { +export function areUUIDsValid (values: string[]) { return isArray(values) && values.every(v => isUUIDValid(v)) } -function isIdOrUUIDValid (value: string) { +export function isIdOrUUIDValid (value: string) { return isIdValid(value) || isUUIDValid(value) } -function isBooleanValid (value: any) { +export function isBooleanValid (value: any) { return typeof value === 'boolean' || (typeof value === 'string' && validator.default.isBoolean(value)) } -function isIntOrNull (value: any) { +export function isIntOrNull (value: any) { return value === null || validator.default.isInt('' + value) } // --------------------------------------------------------------------------- -function isFileValid (options: { +export function isFileValid (options: { files: UploadFilesForCheck maxSize: number | null @@ -108,13 +119,13 @@ function isFileValid (options: { return checkMimetypeRegex(file.mimetype, mimeTypeRegex) } -function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) { +export function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) { return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType) } // --------------------------------------------------------------------------- -function toCompleteUUID (value: string) { +export function toCompleteUUID (value: string) { if (isShortUUID(value)) { try { return shortToUUID(value) @@ -126,11 +137,11 @@ function toCompleteUUID (value: string) { return value } -function toCompleteUUIDs (values: string[]) { +export function toCompleteUUIDs (values: string[]) { return values.map(v => toCompleteUUID(v)) } -function toIntOrNull (value: string) { +export function toIntOrNull (value: string) { const v = toValueOrNull(value) if (v === null || v === undefined) return v @@ -139,7 +150,7 @@ function toIntOrNull (value: string) { return validator.default.toInt('' + v) } -function toBooleanOrNull (value: any) { +export function toBooleanOrNull (value: any) { const v = toValueOrNull(value) if (v === null || v === undefined) return v @@ -148,43 +159,15 @@ function toBooleanOrNull (value: any) { return validator.default.toBoolean('' + v) } -function toValueOrNull (value: string) { +export function toValueOrNull (value: string) { if (value === 'null') return null return value } -function toIntArray (value: any) { +export function toIntArray (value: any) { if (!value) return [] if (isArray(value) === false) return [ validator.default.toInt(value) ] return value.map(v => validator.default.toInt(v)) } - -// --------------------------------------------------------------------------- - -export { - exists, - isArrayOf, - isNotEmptyIntArray, - isArray, - isIntOrNull, - isIdValid, - isSafePath, - isNotEmptyStringArray, - isUUIDValid, - toCompleteUUIDs, - toCompleteUUID, - isIdOrUUIDValid, - isDateValid, - toValueOrNull, - toBooleanOrNull, - isBooleanValid, - toIntOrNull, - areUUIDsValid, - toIntArray, - isFileValid, - isSafePeerTubeFilenameWithoutExtension, - isSafeFilename, - checkMimetypeRegex -} diff --git a/server/core/helpers/ffmpeg/codecs.ts b/server/core/helpers/ffmpeg/codecs.ts index ff98b8f99..54f88b3cd 100644 --- a/server/core/helpers/ffmpeg/codecs.ts +++ b/server/core/helpers/ffmpeg/codecs.ts @@ -3,8 +3,8 @@ import { getAudioStream, getVideoStream } from '@peertube/peertube-ffmpeg' import { logger } from '../logger.js' import { forceNumber } from '@peertube/peertube-core-utils' -export async function getVideoStreamCodec (path: string) { - const videoStream = await getVideoStream(path) +export async function getVideoStreamCodec (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) if (!videoStream) return '' const videoCodec = videoStream.codec_tag_string diff --git a/server/core/helpers/requests.ts b/server/core/helpers/requests.ts index c7dd1172b..9863f53bf 100644 --- a/server/core/helpers/requests.ts +++ b/server/core/helpers/requests.ts @@ -17,7 +17,7 @@ export interface PeerTubeRequestError extends Error { requestHeaders?: any } -type PeerTubeRequestOptions = { +export type PeerTubeRequestOptions = { timeout?: number activityPub?: boolean bodyKBLimit?: number // 1MB @@ -35,7 +35,7 @@ type PeerTubeRequestOptions = { followRedirect?: boolean } & Pick -const peertubeGot = got.extend({ +export const peertubeGot = got.extend({ ...getAgent(), headers: { @@ -116,25 +116,21 @@ const peertubeGot = got.extend({ } }) -function doRequest (url: string, options: PeerTubeRequestOptions = {}) { +export function doRequest (url: string, options: PeerTubeRequestOptions = {}) { const gotOptions = buildGotOptions(options) as OptionsOfTextResponseBody return peertubeGot(url, gotOptions) .catch(err => { throw buildRequestError(err) }) } -function doJSONRequest (url: string, options: PeerTubeRequestOptions = {}) { +export function doJSONRequest (url: string, options: PeerTubeRequestOptions = {}) { const gotOptions = buildGotOptions(options) return peertubeGot(url, { ...gotOptions, responseType: 'json' }) .catch(err => { throw buildRequestError(err) }) } -async function doRequestAndSaveToFile ( - url: string, - destPath: string, - options: PeerTubeRequestOptions = {} -) { +export async function doRequestAndSaveToFile (url: string, destPath: string, options: PeerTubeRequestOptions = {}) { const gotOptions = buildGotOptions({ ...options, timeout: options.timeout ?? REQUEST_TIMEOUTS.FILE }) const outFile = createWriteStream(destPath) @@ -152,7 +148,13 @@ async function doRequestAndSaveToFile ( } } -function getAgent () { +export function generateRequestStream (url: string, options: PeerTubeRequestOptions = {}) { + const gotOptions = buildGotOptions({ ...options, timeout: options.timeout ?? REQUEST_TIMEOUTS.DEFAULT }) + + return peertubeGot.stream(url, { ...gotOptions, isStream: true }) +} + +export function getAgent () { if (!isProxyEnabled()) return {} const proxy = getProxy() @@ -176,27 +178,16 @@ function getAgent () { } } -function getUserAgent () { +export function getUserAgent () { return `PeerTube/${PEERTUBE_VERSION} (+${WEBSERVER.URL})` } -function isBinaryResponse (result: Response) { +export function isBinaryResponse (result: Response) { return BINARY_CONTENT_TYPES.has(result.headers['content-type']) } // --------------------------------------------------------------------------- - -export { - type PeerTubeRequestOptions, - - doRequest, - doJSONRequest, - doRequestAndSaveToFile, - isBinaryResponse, - getAgent, - peertubeGot -} - +// Private // --------------------------------------------------------------------------- function buildGotOptions (options: PeerTubeRequestOptions): OptionsOfUnknownResponseBody { diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts index 73743ebe7..45f896c01 100644 --- a/server/core/initializers/checker-before-init.ts +++ b/server/core/initializers/checker-before-init.ts @@ -135,11 +135,11 @@ async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) { for (const codec of canEncode) { if (codecs[codec] === undefined) { - throw new Error('Unknown codec ' + codec + ' in FFmpeg.') + throw new Error(`Codec ${codec} not found in FFmpeg.`) } if (codecs[codec].canEncode !== true) { - throw new Error('Unavailable encode codec ' + codec + ' in FFmpeg') + throw new Error(`Unavailable encode codec ${codec} in FFmpeg`) } } } diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index 2ecffcb36..f9cbb9151 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -222,6 +222,10 @@ const CONFIG = { CLIENT: { WINDOW_MS: parseDurationToMs(config.get('rates_limit.client.window')), MAX: config.get('rates_limit.client.max') + }, + DOWNLOAD_GENERATE_VIDEO: { + WINDOW_MS: parseDurationToMs(config.get('rates_limit.download_generate_video.window')), + MAX: config.get('rates_limit.download_generate_video.max') } }, TRUST_PROXY: config.get('trust_proxy'), @@ -445,7 +449,8 @@ const CONFIG = { get '2160p' () { return config.get('transcoding.resolutions.2160p') } }, HLS: { - get ENABLED () { return config.get('transcoding.hls.enabled') } + get ENABLED () { return config.get('transcoding.hls.enabled') }, + get SPLIT_AUDIO_AND_VIDEO () { return config.get('transcoding.hls.split_audio_and_video') } }, WEB_VIDEOS: { get ENABLED () { return config.get('transcoding.web_videos.enabled') } @@ -491,6 +496,7 @@ const CONFIG = { get ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION () { return config.get('live.transcoding.always_transcode_original_resolution') }, RESOLUTIONS: { + get '0p' () { return config.get('live.transcoding.resolutions.0p') }, get '144p' () { return config.get('live.transcoding.resolutions.144p') }, get '240p' () { return config.get('live.transcoding.resolutions.240p') }, get '360p' () { return config.get('live.transcoding.resolutions.360p') }, diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 405e8bf22..b869af1c6 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -47,7 +47,7 @@ import { cpus } from 'os' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 860 +const LAST_MIGRATION_VERSION = 865 // --------------------------------------------------------------------------- @@ -214,7 +214,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = { 'federate-video': 1, 'create-user-export': 1, 'import-user-archive': 1, - 'video-transcription': 1 + 'video-transcription': 2 } // Excluded keys are jobs that can be configured by admins const JOB_CONCURRENCY: { [id in Exclude]: number } = { @@ -327,6 +327,7 @@ const AP_CLEANER = { const REQUEST_TIMEOUTS = { DEFAULT: 7000, // 7 seconds FILE: 30000, // 30 seconds + VIDEO_FILE: 60000, // 1 minute REDUNDANCY: JOB_TTL['video-redundancy'] } @@ -873,9 +874,10 @@ const STATIC_PATHS = { PRIVATE_HLS: '/static/streaming-playlists/hls/private/' } } -const STATIC_DOWNLOAD_PATHS = { +const DOWNLOAD_PATHS = { TORRENTS: '/download/torrents/', - VIDEOS: '/download/videos/', + GENERATE_VIDEO: '/download/videos/generate/', + WEB_VIDEOS: '/download/web-videos/', HLS_VIDEOS: '/download/streaming-playlists/hls/videos/', USER_EXPORTS: '/download/user-exports/', ORIGINAL_VIDEO_FILE: '/download/original-video-files/' @@ -1337,7 +1339,7 @@ export { OVERVIEWS, SCHEDULER_INTERVALS_MS, REPEAT_JOBS, - STATIC_DOWNLOAD_PATHS, + DOWNLOAD_PATHS, MIMETYPES, CRAWL_REQUEST_CONCURRENCY, DEFAULT_AUDIO_RESOLUTION, diff --git a/server/core/initializers/migrations/0865-video-file-streams.ts b/server/core/initializers/migrations/0865-video-file-streams.ts new file mode 100644 index 000000000..a063953a5 --- /dev/null +++ b/server/core/initializers/migrations/0865-video-file-streams.ts @@ -0,0 +1,53 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + const { transaction } = utils + + { + await utils.queryInterface.addColumn('videoFile', 'formatFlags', { + type: Sequelize.INTEGER, + defaultValue: 2, // fragmented + allowNull: false + }, { transaction }) + + // Web videos + const query = 'UPDATE "videoFile" SET "formatFlags" = 1 WHERE "videoId" IS NOT NULL' + await utils.sequelize.query(query, { transaction }) + + await utils.queryInterface.changeColumn('videoFile', 'formatFlags', { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: false + }, { transaction }) + } + + { + await utils.queryInterface.addColumn('videoFile', 'streams', { + type: Sequelize.INTEGER, + defaultValue: 3, // audio + video + allowNull: false + }, { transaction }) + + // Case where there is only an audio stream + const query = 'UPDATE "videoFile" SET "streams" = 2 WHERE "resolution" = 0' + await utils.sequelize.query(query, { transaction }) + + await utils.queryInterface.changeColumn('videoFile', 'streams', { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: false + }, { transaction }) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + down, up +} diff --git a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts index c6d265792..7d6279b81 100644 --- a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts @@ -7,13 +7,17 @@ import { ActivityTagObject, ActivityUrlObject, ActivityVideoUrlObject, + VideoFileFormatFlag, + VideoFileStream, VideoObject, VideoPrivacy, + VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models' +import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { hasAPPublic } from '@server/helpers/activity-pub-utils.js' import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js' -import { isArray } from '@server/helpers/custom-validators/misc.js' +import { exists, isArray } from '@server/helpers/custom-validators/misc.js' import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js' import { generateImageFilename } from '@server/helpers/image-utils.js' import { getExtFromMimetype } from '@server/helpers/video.js' @@ -23,7 +27,7 @@ import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoFileModel } from '@server/models/video/video-file.js' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js' import { FilteredModelAttributes } from '@server/types/index.js' -import { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId, isStreamingPlaylist } from '@server/types/models/index.js' +import { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoId, isStreamingPlaylist } from '@server/types/models/index.js' import { decode as magnetUriDecode } from 'magnet-uri' import { basename, extname } from 'path' import { getDurationFromActivityStream } from '../../activity.js' @@ -48,6 +52,8 @@ export function getTagsFromObject (videoObject: VideoObject) { .map(t => t.name) } +// --------------------------------------------------------------------------- + export function getFileAttributesFromUrl ( videoOrPlaylist: MVideo | MStreamingPlaylistVideo, urls: (ActivityTagObject | ActivityUrlObject)[] @@ -67,20 +73,21 @@ export function getFileAttributesFromUrl ( const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType) const resolution = fileUrl.height - const videoId = isStreamingPlaylist(videoOrPlaylist) ? null : videoOrPlaylist.id - const videoStreamingPlaylistId = isStreamingPlaylist(videoOrPlaylist) - ? videoOrPlaylist.id - : null + const [ videoId, videoStreamingPlaylistId ] = isStreamingPlaylist(videoOrPlaylist) + ? [ null, videoOrPlaylist.id ] + : [ videoOrPlaylist.id, null ] const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl }) - const attribute = { + const attribute: Partial> = { extname, resolution, size: fileUrl.size, - fps: fileUrl.fps || -1, + fps: exists(fileUrl.fps) && fileUrl.fps >= 0 + ? fileUrl.fps + : -1, metadataUrl: metadata?.href, @@ -95,6 +102,9 @@ export function getFileAttributesFromUrl ( torrentFilename, torrentUrl, + formatFlags: buildFileFormatFlags(fileUrl, isStreamingPlaylist(videoOrPlaylist)), + streams: buildFileStreams(fileUrl, resolution), + // This is a video file owned by a video or by a streaming playlist videoId, videoStreamingPlaylistId @@ -106,6 +116,49 @@ export function getFileAttributesFromUrl ( return attributes } +function buildFileFormatFlags (fileUrl: ActivityVideoUrlObject, isStreamingPlaylist: boolean) { + const attachment = fileUrl.attachment || [] + + const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'peertube_format_flag') + if (formatHints.length === 0) { + return isStreamingPlaylist + ? VideoFileFormatFlag.FRAGMENTED + : VideoFileFormatFlag.WEB_VIDEO + } + + let formatFlags = VideoFileFormatFlag.NONE + + for (const hint of formatHints) { + if (hint.value === 'fragmented') formatFlags |= VideoFileFormatFlag.FRAGMENTED + else if (hint.value === 'web-video') formatFlags |= VideoFileFormatFlag.WEB_VIDEO + } + + return formatFlags +} + +function buildFileStreams (fileUrl: ActivityVideoUrlObject, resolution: number) { + const attachment = fileUrl.attachment || [] + + const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'ffprobe_codec_type') + + if (formatHints.length === 0) { + if (resolution === VideoResolution.H_NOVIDEO) return VideoFileStream.AUDIO + + return VideoFileStream.VIDEO | VideoFileStream.AUDIO + } + + let streams = VideoFileStream.NONE + + for (const hint of formatHints) { + if (hint.value === 'audio') streams |= VideoFileStream.AUDIO + else if (hint.value === 'video') streams |= VideoFileStream.VIDEO + } + + return streams +} + +// --------------------------------------------------------------------------- + export function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) { const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) if (playlistUrls.length === 0) return [] diff --git a/server/core/lib/hls.ts b/server/core/lib/hls.ts index a2d9ca024..61c9608e3 100644 --- a/server/core/lib/hls.ts +++ b/server/core/lib/hls.ts @@ -1,6 +1,6 @@ -import { uniqify, uuidRegex } from '@peertube/peertube-core-utils' -import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg' -import { FileStorage } from '@peertube/peertube-models' +import { sortBy, uniqify, uuidRegex } from '@peertube/peertube-core-utils' +import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg' +import { FileStorage, VideoResolution } from '@peertube/peertube-models' import { sha256 } from '@peertube/peertube-node-utils' import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js' import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm' @@ -23,7 +23,7 @@ import { VideoPathManager } from './video-path-manager.js' const lTags = loggerTagsFactory('hls') -async function updateStreamingPlaylistsInfohashesIfNeeded () { +export async function updateStreamingPlaylistsInfohashesIfNeeded () { const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion() // Use separate SQL queries, because we could have many videos to update @@ -39,7 +39,7 @@ async function updateStreamingPlaylistsInfohashesIfNeeded () { } } -async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) { +export async function updateM3U8AndShaPlaylist (video: MVideo, playlist: MStreamingPlaylist) { try { let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist) playlistWithFiles = await updateSha256VODSegments(video, playlist) @@ -60,36 +60,62 @@ async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamin // Avoid concurrency issues when updating streaming playlist files const playlistFilesQueue = new PQueue({ concurrency: 1 }) -function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise { +export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise { return playlistFilesQueue.add(async () => { const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id) - const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ] + const extMedia: string[] = [] + const extStreamInfo: string[] = [] + let separatedAudioCodec: string - for (const file of playlist.VideoFiles) { + const splitAudioAndVideo = playlist.hasAudioAndVideoSplitted() + + // Sort to have the audio resolution first (if it exists) + for (const file of sortBy(playlist.VideoFiles, 'resolution')) { const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => { - const size = await getVideoStreamDimensionsInfo(videoFilePath) + const probe = await ffprobePromise(videoFilePath) + + if (splitAudioAndVideo && file.resolution === VideoResolution.H_NOVIDEO) { + separatedAudioCodec = await getAudioStreamCodec(videoFilePath, probe) + } + + const size = await getVideoStreamDimensionsInfo(videoFilePath, probe) const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file) - const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}` + const resolution = file.resolution === VideoResolution.H_NOVIDEO + ? '' + : `,RESOLUTION=${size?.width || 0}x${size?.height || 0}` - let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}` + let line = `#EXT-X-STREAM-INF:${bandwidth}${resolution}` if (file.fps) line += ',FRAME-RATE=' + file.fps const codecs = await Promise.all([ - getVideoStreamCodec(videoFilePath), - getAudioStreamCodec(videoFilePath) + getVideoStreamCodec(videoFilePath, probe), + separatedAudioCodec || getAudioStreamCodec(videoFilePath, probe) ]) line += `,CODECS="${codecs.filter(c => !!c).join(',')}"` - masterPlaylists.push(line) - masterPlaylists.push(playlistFilename) + if (splitAudioAndVideo) { + line += `,AUDIO="audio"` + } + + // Don't include audio only resolution as a regular "video" resolution + // Some player may use it automatically and so the user would not have a video stream + // But if it's the only resolution we can treat it as a regular stream + if (resolution || playlist.VideoFiles.length === 1) { + extStreamInfo.push(line) + extStreamInfo.push(playlistFilename) + } else if (splitAudioAndVideo) { + extMedia.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`) + } }) } + const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMedia, '', ...extStreamInfo ] + if (playlist.playlistFilename) { await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename) } @@ -111,7 +137,7 @@ function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist // --------------------------------------------------------------------------- -function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise { +export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise { return playlistFilesQueue.add(async () => { const json: { [filename: string]: { [range: string]: string } } = {} @@ -162,12 +188,12 @@ function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist // --------------------------------------------------------------------------- -async function buildSha256Segment (segmentPath: string) { +export async function buildSha256Segment (segmentPath: string) { const buf = await readFile(segmentPath) return sha256(buf) } -function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) { +export function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) { let timer let remainingBodyKBLimit = bodyKBLimit @@ -240,7 +266,7 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, // --------------------------------------------------------------------------- -async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) { +export async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) { const content = await readFile(playlistPath, 'utf8') const newContent = content.replace(new RegExp(`${uuidRegex}-\\d+-fragmented.mp4`, 'g'), newVideoFilename) @@ -250,23 +276,12 @@ async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename // --------------------------------------------------------------------------- -function injectQueryToPlaylistUrls (content: string, queryString: string) { +export function injectQueryToPlaylistUrls (content: string, queryString: string) { return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString) } // --------------------------------------------------------------------------- - -export { - updateMasterHLSPlaylist, - updateSha256VODSegments, - buildSha256Segment, - downloadPlaylistSegments, - updateStreamingPlaylistsInfohashesIfNeeded, - updatePlaylistAfterFileChange, - injectQueryToPlaylistUrls, - renameVideoFileInPlaylist -} - +// Private // --------------------------------------------------------------------------- function getRangesFromPlaylist (playlistContent: string) { diff --git a/server/core/lib/job-queue/handlers/generate-storyboard.ts b/server/core/lib/job-queue/handlers/generate-storyboard.ts index b6f069c01..cfbfe24c4 100644 --- a/server/core/lib/job-queue/handlers/generate-storyboard.ts +++ b/server/core/lib/job-queue/handlers/generate-storyboard.ts @@ -1,5 +1,5 @@ -import { FFmpegImage, ffprobePromise, getVideoStreamDimensionsInfo, isAudioFile } from '@peertube/peertube-ffmpeg' -import { GenerateStoryboardPayload } from '@peertube/peertube-models' +import { FFmpegImage, ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg' +import { GenerateStoryboardPayload, VideoFileStream } from '@peertube/peertube-models' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js' import { generateImageFilename } from '@server/helpers/image-utils.js' @@ -34,16 +34,14 @@ async function processGenerateStoryboard (job: Job): Promise { return } - const inputFile = video.getMaxQualityFile() + const inputFile = video.getMaxQualityFile(VideoFileStream.VIDEO) + if (!inputFile) { + logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags) + return + } await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => { const probe = await ffprobePromise(videoPath) - const isAudio = await isAudioFile(videoPath, probe) - - if (isAudio) { - logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags) - return - } const videoStreamInfo = await getVideoStreamDimensionsInfo(videoPath, probe) let spriteHeight: number diff --git a/server/core/lib/job-queue/handlers/shared/move-video.ts b/server/core/lib/job-queue/handlers/shared/move-video.ts index a9c90a0b4..acf774359 100644 --- a/server/core/lib/job-queue/handlers/shared/move-video.ts +++ b/server/core/lib/job-queue/handlers/shared/move-video.ts @@ -44,7 +44,7 @@ export async function moveToJob (options: { try { const source = await VideoSourceModel.loadLatest(video.id) - if (source) { + if (source?.keptOriginalFilename) { logger.debug(`Moving video source ${source.keptOriginalFilename} file of video ${video.uuid}`, lTags) await moveVideoSourceFile(source) diff --git a/server/core/lib/job-queue/handlers/transcoding-job-builder.ts b/server/core/lib/job-queue/handlers/transcoding-job-builder.ts index 9e29b86da..185739bd6 100644 --- a/server/core/lib/job-queue/handlers/transcoding-job-builder.ts +++ b/server/core/lib/job-queue/handlers/transcoding-job-builder.ts @@ -1,10 +1,10 @@ -import { Job } from 'bullmq' +import { pick } from '@peertube/peertube-core-utils' +import { TranscodingJobBuilderPayload, VideoFileStream } from '@peertube/peertube-models' import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job.js' import { UserModel } from '@server/models/user/user.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import { VideoModel } from '@server/models/video/video.js' -import { pick } from '@peertube/peertube-core-utils' -import { TranscodingJobBuilderPayload } from '@peertube/peertube-models' +import { Job } from 'bullmq' import { logger } from '../../../helpers/logger.js' import { JobQueue } from '../job-queue.js' @@ -16,7 +16,7 @@ async function processTranscodingJobBuilder (job: Job) { if (payload.optimizeJob) { const video = await VideoModel.loadFull(payload.videoUUID) const user = await UserModel.loadByVideoId(video.id) - const videoFile = video.getMaxQualityFile() + const videoFile = video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO) await createOptimizeOrMergeAudioJobs({ ...pick(payload.optimizeJob, [ 'isNewVideo' ]), diff --git a/server/core/lib/job-queue/handlers/video-import.ts b/server/core/lib/job-queue/handlers/video-import.ts index 14337a85e..15583f973 100644 --- a/server/core/lib/job-queue/handlers/video-import.ts +++ b/server/core/lib/job-queue/handlers/video-import.ts @@ -129,7 +129,7 @@ type ProcessFileOptions = { } async function processFile (downloader: () => Promise, videoImport: MVideoImportDefault, options: ProcessFileOptions) { let tmpVideoPath: string - let videoFile: VideoFileModel + let videoFile: MVideoFile try { // Download video from youtubeDL @@ -163,7 +163,7 @@ async function processFile (downloader: () => Promise, videoImport: MVid videoImport, video: videoImport.Video, videoFilePath: tmpVideoPath, - videoFile, + videoFile: videoFile as VideoFileModel, user: videoImport.User } const acceptedResult = await Hooks.wrapFun(isPostImportVideoAccepted, acceptParameters, hookName) diff --git a/server/core/lib/job-queue/handlers/video-live-ending.ts b/server/core/lib/job-queue/handlers/video-live-ending.ts index f743e8357..b8c9020f7 100644 --- a/server/core/lib/job-queue/handlers/video-live-ending.ts +++ b/server/core/lib/job-queue/handlers/video-live-ending.ts @@ -1,5 +1,5 @@ import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' -import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@peertube/peertube-models' +import { ThumbnailType, VideoFileStream, VideoLiveEndingPayload, VideoState } from '@peertube/peertube-models' import { peertubeTruncate } from '@server/helpers/core-utils.js' import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url.js' @@ -11,7 +11,7 @@ import { getHLSDirectory, getLiveReplayBaseDirectory } from '@server/lib/paths.js' -import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js' +import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded, updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js' import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding.js' import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js' import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js' @@ -25,7 +25,15 @@ import { VideoLiveSessionModel } from '@server/models/video/video-live-session.j import { VideoLiveModel } from '@server/models/video/video-live.js' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js' import { VideoModel } from '@server/models/video/video.js' -import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models/index.js' +import { + MThumbnail, + MVideo, + MVideoLive, + MVideoLiveSession, + MVideoThumbnail, + MVideoWithAllFiles, + MVideoWithFileThumbnail +} from '@server/types/models/index.js' import { Job } from 'bullmq' import { remove } from 'fs-extra/esm' import { readdir } from 'fs/promises' @@ -97,7 +105,7 @@ export { // --------------------------------------------------------------------------- async function saveReplayToExternalVideo (options: { - liveVideo: MVideo + liveVideo: MVideoThumbnail liveSession: MVideoLiveSession publishedAt: string replayDirectory: string @@ -159,21 +167,13 @@ async function saveReplayToExternalVideo (options: { try { await assignReplayFilesToVideo({ video: replayVideo, replayDirectory }) + logger.info(`Removing replay directory ${replayDirectory}`, lTags(liveVideo.uuid)) await remove(replayDirectory) } finally { inputFileMutexReleaser() } - const thumbnails = await generateLocalVideoMiniature({ - video: replayVideo, - videoFile: replayVideo.getMaxQualityFile(), - types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ], - ffprobe: undefined - }) - - for (const thumbnail of thumbnails) { - await replayVideo.addAndSaveThumbnail(thumbnail) - } + await copyOrRegenerateThumbnails({ liveVideo, replayVideo }) await createStoryboardJob(replayVideo) await createTranscriptionTaskIfNeeded(replayVideo) @@ -181,6 +181,40 @@ async function saveReplayToExternalVideo (options: { await moveToNextState({ video: replayVideo, isNewVideo: true }) } +async function copyOrRegenerateThumbnails (options: { + liveVideo: MVideoThumbnail + replayVideo: MVideoWithFileThumbnail +}) { + const { liveVideo, replayVideo } = options + + let thumbnails: MThumbnail[] = [] + const preview = liveVideo.getPreview() + + if (preview?.automaticallyGenerated === false) { + thumbnails = await Promise.all( + [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ].map(type => { + return updateLocalVideoMiniatureFromExisting({ + inputPath: preview.getPath(), + video: replayVideo, + type, + automaticallyGenerated: false + }) + }) + ) + } else { + thumbnails = await generateLocalVideoMiniature({ + video: replayVideo, + videoFile: replayVideo.getMaxQualityFile(VideoFileStream.VIDEO) || replayVideo.getMaxQualityFile(VideoFileStream.AUDIO), + types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ], + ffprobe: undefined + }) + } + + for (const thumbnail of thumbnails) { + await replayVideo.addAndSaveThumbnail(thumbnail) + } +} + async function replaceLiveByReplay (options: { video: MVideo liveSession: MVideoLiveSession diff --git a/server/core/lib/job-queue/handlers/video-studio-edition.ts b/server/core/lib/job-queue/handlers/video-studio-edition.ts index a21491a36..cc5f49d86 100644 --- a/server/core/lib/job-queue/handlers/video-studio-edition.ts +++ b/server/core/lib/job-queue/handlers/video-studio-edition.ts @@ -1,17 +1,4 @@ -import { Job } from 'bullmq' -import { remove } from 'fs-extra/esm' -import { join } from 'path' -import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js' -import { CONFIG } from '@server/initializers/config.js' -import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js' -import { isUserQuotaValid } from '@server/lib/user.js' -import { VideoPathManager } from '@server/lib/video-path-manager.js' -import { approximateIntroOutroAdditionalSize, onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio.js' -import { UserModel } from '@server/models/user/user.js' -import { VideoModel } from '@server/models/video/video.js' -import { MVideo, MVideoFullLight } from '@server/types/models/index.js' import { pick } from '@peertube/peertube-core-utils' -import { buildUUID } from '@peertube/peertube-node-utils' import { FFmpegEdition } from '@peertube/peertube-ffmpeg' import { VideoStudioEditionPayload, @@ -22,6 +9,20 @@ import { VideoStudioTaskPayload, VideoStudioTaskWatermarkPayload } from '@peertube/peertube-models' +import { buildUUID } from '@peertube/peertube-node-utils' +import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js' +import { CONFIG } from '@server/initializers/config.js' +import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js' +import { isUserQuotaValid } from '@server/lib/user.js' +import { VideoPathManager } from '@server/lib/video-path-manager.js' +import { approximateIntroOutroAdditionalSize, onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio.js' +import { UserModel } from '@server/models/user/user.js' +import { VideoModel } from '@server/models/video/video.js' +import { MVideo, MVideoFullLight } from '@server/types/models/index.js' +import { MutexInterface } from 'async-mutex' +import { Job } from 'bullmq' +import { remove } from 'fs-extra/esm' +import { extname, join } from 'path' import { logger, loggerTagsFactory } from '../../../helpers/logger.js' const lTagsBase = loggerTagsFactory('video-studio') @@ -32,6 +33,8 @@ async function processVideoStudioEdition (job: Job) { logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags) + let inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(payload.videoUUID) + try { const video = await VideoModel.loadFull(payload.videoUUID) @@ -45,18 +48,28 @@ async function processVideoStudioEdition (job: Job) { await checkUserQuotaOrThrow(video, payload) - const inputFile = video.getMaxQualityFile() + await video.reload() - const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => { + const editionResultPath = await VideoPathManager.Instance.makeAvailableMaxQualityFiles(video, async ({ + videoPath: originalVideoFilePath, + separatedAudioPath + }) => { let tmpInputFilePath: string let outputPath: string for (const task of payload.tasks) { - const outputFilename = buildUUID() + inputFile.extname + const outputFilename = buildUUID() + extname(originalVideoFilePath) outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename) await processTask({ - inputPath: tmpInputFilePath ?? originalFilePath, + videoInputPath: tmpInputFilePath ?? originalVideoFilePath, + + separatedAudioInputPath: tmpInputFilePath + ? undefined + : separatedAudioPath, + + inputFileMutexReleaser, + video, outputPath, task, @@ -67,6 +80,7 @@ async function processVideoStudioEdition (job: Job) { // For the next iteration tmpInputFilePath = outputPath + inputFileMutexReleaser = undefined } return outputPath @@ -79,6 +93,8 @@ async function processVideoStudioEdition (job: Job) { await safeCleanupStudioTMPFiles(payload.tasks) throw err + } finally { + if (inputFileMutexReleaser) inputFileMutexReleaser() } } @@ -91,7 +107,11 @@ export { // --------------------------------------------------------------------------- type TaskProcessorOptions = { - inputPath: string + videoInputPath: string + separatedAudioInputPath?: string + + inputFileMutexReleaser: MutexInterface.Releaser + outputPath: string video: MVideo task: T @@ -122,7 +142,7 @@ function processAddIntroOutro (options: TaskProcessorOptions) { logger.debug('Will cut the video.', { options, ...lTags }) return buildFFmpegEdition().cutVideo({ - ...pick(options, [ 'inputPath', 'outputPath' ]), + ...pick(options, [ 'inputFileMutexReleaser', 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]), start: task.options.start, end: task.options.end @@ -150,7 +170,7 @@ function processAddWatermark (options: TaskProcessorOptions !!v) - await VideoPathManager.Instance.makeAvailableVideoFile(videoFileInput.withVideoOrPlaylist(videoOrStreamingPlaylist), videoInputPath => { + await VideoPathManager.Instance.makeAvailableVideoFiles(videoFileInputs, ([ videoPath, separatedAudioPath ]) => { return generateHlsPlaylistResolution({ video, - videoInputPath, + + videoInputPath: videoPath, + separatedAudioInputPath: separatedAudioPath, + inputFileMutexReleaser, resolution: payload.resolution, fps: payload.fps, copyCodecs: payload.copyCodecs, + separatedAudio: payload.separatedAudio, job }) }) @@ -146,5 +150,5 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, videoArg: await removeAllWebVideoFiles(video) } - await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video }) + await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video }) } diff --git a/server/core/lib/job-queue/job-queue.ts b/server/core/lib/job-queue/job-queue.ts index f3ecd952c..e387f5084 100644 --- a/server/core/lib/job-queue/job-queue.ts +++ b/server/core/lib/job-queue/job-queue.ts @@ -368,6 +368,8 @@ class JobQueue { createSequentialJobFlow (...jobs: ((CreateJobArgument & CreateJobOptions) | undefined)[]) { let lastJob: FlowJob + logger.debug('Creating jobs in local job queue', { jobs }) + for (const job of jobs) { if (!job) continue diff --git a/server/core/lib/live/live-manager.ts b/server/core/lib/live/live-manager.ts index 8741451db..e0cb87fad 100644 --- a/server/core/lib/live/live-manager.ts +++ b/server/core/lib/live/live-manager.ts @@ -4,9 +4,10 @@ import { getVideoStreamBitrate, getVideoStreamDimensionsInfo, getVideoStreamFPS, - hasAudioStream + hasAudioStream, + hasVideoStream } from '@peertube/peertube-ffmpeg' -import { LiveVideoError, LiveVideoErrorType, VideoState } from '@peertube/peertube-models' +import { LiveVideoError, LiveVideoErrorType, VideoResolution, VideoState } from '@peertube/peertube-models' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config.js' @@ -286,13 +287,24 @@ class LiveManager { const now = Date.now() const probe = await ffprobePromise(inputLocalUrl) - const [ { resolution, ratio }, fps, bitrate, hasAudio ] = await Promise.all([ + const [ { resolution, ratio }, fps, bitrate, hasAudio, hasVideo ] = await Promise.all([ getVideoStreamDimensionsInfo(inputLocalUrl, probe), getVideoStreamFPS(inputLocalUrl, probe), getVideoStreamBitrate(inputLocalUrl, probe), - hasAudioStream(inputLocalUrl, probe) + hasAudioStream(inputLocalUrl, probe), + hasVideoStream(inputLocalUrl, probe) ]) + if (!hasAudio && !hasVideo) { + logger.warn( + 'Not audio and video streams were found for video %s. Refusing stream %s.', + video.uuid, streamKey, lTags(sessionId, video.uuid) + ) + + this.videoSessions.delete(video.uuid) + return this.abortSession(sessionId) + } + logger.info( '%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)', inputLocalUrl, Date.now() - now, bitrate, fps, resolution, lTags(sessionId, video.uuid) @@ -304,6 +316,16 @@ class LiveManager { { video } ) + if (!hasAudio && allResolutions.length === 1 && allResolutions[0] === VideoResolution.H_NOVIDEO) { + logger.warn( + 'Cannot stream live to audio only because no video stream is available for video %s. Refusing stream %s.', + video.uuid, streamKey, lTags(sessionId, video.uuid) + ) + + this.videoSessions.delete(video.uuid) + return this.abortSession(sessionId) + } + logger.info( 'Handling live video of original resolution %d.', resolution, { allResolutions, ...lTags(sessionId, video.uuid) } @@ -322,6 +344,7 @@ class LiveManager { ratio, allResolutions, hasAudio, + hasVideo, probe }) } @@ -340,12 +363,15 @@ class LiveManager { ratio: number allResolutions: number[] hasAudio: boolean + hasVideo: boolean probe: FfprobeData }) { - const { sessionId, videoLive, user, ratio } = options + const { sessionId, videoLive, user, ratio, allResolutions } = options const videoUUID = videoLive.Video.uuid const localLTags = lTags(sessionId, videoUUID) + const audioOnlyOutput = allResolutions.every(r => r === VideoResolution.H_NOVIDEO) + const liveSession = await this.saveStartingSession(videoLive) LiveQuotaStore.Instance.addNewLive(user.id, sessionId) @@ -356,10 +382,10 @@ class LiveManager { videoLive, user, - ...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio', 'probe' ]) + ...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio', 'hasVideo', 'probe' ]) }) - muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, localLTags })) + muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, audioOnlyOutput, localLTags })) muxingSession.on('bad-socket-health', ({ videoUUID }) => { logger.error( @@ -421,10 +447,11 @@ class LiveManager { private async publishAndFederateLive (options: { live: MVideoLiveVideo + audioOnlyOutput: boolean ratio: number localLTags: { tags: (string | number)[] } }) { - const { live, ratio, localLTags } = options + const { live, ratio, audioOnlyOutput, localLTags } = options const videoId = live.videoId @@ -435,7 +462,10 @@ class LiveManager { video.state = VideoState.PUBLISHED video.publishedAt = new Date() - video.aspectRatio = ratio + video.aspectRatio = audioOnlyOutput + ? 0 + : ratio + await video.save() live.Video = video @@ -546,16 +576,24 @@ class LiveManager { } private buildAllResolutionsToTranscode (originResolution: number, hasAudio: boolean) { + if (!CONFIG.LIVE.TRANSCODING.ENABLED) return [ originResolution ] + const includeInput = CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION - const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED - ? computeResolutionsToTranscode({ input: originResolution, type: 'live', includeInput, strictLower: false, hasAudio }) - : [] + const resolutionsEnabled = computeResolutionsToTranscode({ + input: originResolution, + type: 'live', + includeInput, + strictLower: false, + hasAudio + }) - if (resolutionsEnabled.length === 0) { - return [ originResolution ] + if (hasAudio && resolutionsEnabled.length !== 0 && !resolutionsEnabled.includes(VideoResolution.H_NOVIDEO)) { + resolutionsEnabled.push(VideoResolution.H_NOVIDEO) } + if (resolutionsEnabled.length === 0) return [ originResolution ] + return resolutionsEnabled } diff --git a/server/core/lib/live/shared/muxing-session.ts b/server/core/lib/live/shared/muxing-session.ts index 8ff2d51a7..1b5b8f4bf 100644 --- a/server/core/lib/live/shared/muxing-session.ts +++ b/server/core/lib/live/shared/muxing-session.ts @@ -1,5 +1,12 @@ import { wait } from '@peertube/peertube-core-utils' -import { FileStorage, LiveVideoError, VideoStreamingPlaylistType } from '@peertube/peertube-models' +import { + FileStorage, + LiveVideoError, + VideoFileFormatFlag, + VideoFileStream, + VideoResolution, + VideoStreamingPlaylistType +} from '@peertube/peertube-models' import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' import { LoggerTagsFn, logger, loggerTagsFactory } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' @@ -71,6 +78,7 @@ class MuxingSession extends EventEmitter { private readonly ratio: number private readonly hasAudio: boolean + private readonly hasVideo: boolean private readonly probe: FfprobeData @@ -119,6 +127,7 @@ class MuxingSession extends EventEmitter { ratio: number allResolutions: number[] hasAudio: boolean + hasVideo: boolean probe: FfprobeData }) { super() @@ -137,6 +146,7 @@ class MuxingSession extends EventEmitter { this.ratio = options.ratio this.probe = options.probe + this.hasVideo = options.hasVideo this.hasAudio = options.hasAudio this.allResolutions = options.allResolutions @@ -154,12 +164,14 @@ class MuxingSession extends EventEmitter { async runMuxing () { this.streamingPlaylist = await this.createLivePlaylist() + const toTranscode = this.buildToTranscode() + this.createLiveShaStore() - this.createFiles() + this.createFiles(toTranscode) await this.prepareDirectories() - this.transcodingWrapper = this.buildTranscodingWrapper() + this.transcodingWrapper = this.buildTranscodingWrapper(toTranscode) this.transcodingWrapper.on('end', () => this.onTranscodedEnded()) this.transcodingWrapper.on('error', () => this.onTranscodingError()) @@ -295,16 +307,18 @@ class MuxingSession extends EventEmitter { } } - private createFiles () { - for (let i = 0; i < this.allResolutions.length; i++) { - const resolution = this.allResolutions[i] - + private createFiles (toTranscode: { fps: number, resolution: number }[]) { + for (const { resolution, fps } of toTranscode) { const file = new VideoFileModel({ resolution, + fps, size: -1, extname: '.ts', infoHash: null, - fps: this.fps, + formatFlags: VideoFileFormatFlag.NONE, + streams: resolution === VideoResolution.H_NOVIDEO + ? VideoFileStream.AUDIO + : VideoFileStream.VIDEO, storage: this.streamingPlaylist.storage, videoStreamingPlaylistId: this.streamingPlaylist.id }) @@ -484,7 +498,7 @@ class MuxingSession extends EventEmitter { }) } - private buildTranscodingWrapper () { + private buildTranscodingWrapper (toTranscode: { fps: number, resolution: number }[]) { const options = { streamingPlaylist: this.streamingPlaylist, videoLive: this.videoLive, @@ -495,26 +509,12 @@ class MuxingSession extends EventEmitter { inputLocalUrl: this.inputLocalUrl, inputPublicUrl: this.inputPublicUrl, - toTranscode: this.allResolutions.map(resolution => { - let toTranscodeFPS: number + toTranscode, - try { - toTranscodeFPS = computeOutputFPS({ inputFPS: this.fps, resolution }) - } catch (err) { - err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM - throw err - } - - return { - resolution, - fps: toTranscodeFPS - } - }), - - fps: this.fps, bitrate: this.bitrate, ratio: this.ratio, hasAudio: this.hasAudio, + hasVideo: this.hasVideo, probe: this.probe, segmentListSize: VIDEO_LIVE.SEGMENTS_LIST_SIZE, @@ -537,6 +537,25 @@ class MuxingSession extends EventEmitter { private getPlaylistNameFromTS (segmentPath: string) { return `${this.getPlaylistIdFromTS(segmentPath)}.m3u8` } + + private buildToTranscode () { + return this.allResolutions.map(resolution => { + let toTranscodeFPS: number + + if (resolution === VideoResolution.H_NOVIDEO) { + return { resolution, fps: 0 } + } + + try { + toTranscodeFPS = computeOutputFPS({ inputFPS: this.fps, resolution }) + } catch (err) { + err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM + throw err + } + + return { resolution, fps: toTranscodeFPS } + }) + } } // --------------------------------------------------------------------------- diff --git a/server/core/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts b/server/core/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts index 30377d6e0..b1a03899e 100644 --- a/server/core/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts +++ b/server/core/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts @@ -30,7 +30,6 @@ interface AbstractTranscodingWrapperOptions { inputLocalUrl: string inputPublicUrl: string - fps: number toTranscode: { resolution: number fps: number @@ -38,7 +37,9 @@ interface AbstractTranscodingWrapperOptions { bitrate: number ratio: number + hasAudio: boolean + hasVideo: boolean probe: FfprobeData segmentListSize: number @@ -59,10 +60,10 @@ abstract class AbstractTranscodingWrapper extends EventEmitter { protected readonly inputLocalUrl: string protected readonly inputPublicUrl: string - protected readonly fps: number protected readonly bitrate: number protected readonly ratio: number protected readonly hasAudio: boolean + protected readonly hasVideo: boolean protected readonly probe: FfprobeData protected readonly segmentListSize: number @@ -89,12 +90,12 @@ abstract class AbstractTranscodingWrapper extends EventEmitter { this.inputLocalUrl = options.inputLocalUrl this.inputPublicUrl = options.inputPublicUrl - this.fps = options.fps this.toTranscode = options.toTranscode this.bitrate = options.bitrate this.ratio = options.ratio this.hasAudio = options.hasAudio + this.hasVideo = options.hasVideo this.probe = options.probe this.segmentListSize = options.segmentListSize diff --git a/server/core/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts b/server/core/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts index d5b73dd59..0a5feb027 100644 --- a/server/core/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts +++ b/server/core/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts @@ -1,10 +1,10 @@ -import { FfmpegCommand } from 'fluent-ffmpeg' +import { FFmpegLive } from '@peertube/peertube-ffmpeg' import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js' import { logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' import { VIDEO_LIVE } from '@server/initializers/constants.js' import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js' -import { FFmpegLive } from '@peertube/peertube-ffmpeg' +import { FfmpegCommand } from 'fluent-ffmpeg' import { getLiveSegmentTime } from '../../live-utils.js' import { AbstractTranscodingWrapper } from './abstract-transcoding-wrapper.js' @@ -32,7 +32,10 @@ export class FFmpegTranscodingWrapper extends AbstractTranscodingWrapper { ratio: this.ratio, probe: this.probe, - hasAudio: this.hasAudio + hasAudio: this.hasAudio, + hasVideo: this.hasVideo, + + splitAudioAndVideo: true }) : this.buildFFmpegLive().getLiveMuxingCommand({ inputUrl: this.inputLocalUrl, diff --git a/server/core/lib/notifier/notifier.ts b/server/core/lib/notifier/notifier.ts index 0048340d8..59ab31331 100644 --- a/server/core/lib/notifier/notifier.ts +++ b/server/core/lib/notifier/notifier.ts @@ -329,11 +329,11 @@ class Notifier { private isEmailEnabled (user: MUser, value: UserNotificationSettingValueType) { if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified === false) return false - return value & UserNotificationSettingValue.EMAIL + return (value & UserNotificationSettingValue.EMAIL) === UserNotificationSettingValue.EMAIL } private isWebNotificationEnabled (value: UserNotificationSettingValueType) { - return value & UserNotificationSettingValue.WEB + return (value & UserNotificationSettingValue.WEB) === UserNotificationSettingValue.WEB } private async sendNotifications (models: (new (payload: T) => AbstractNotification)[], payload: T) { diff --git a/server/core/lib/runners/job-handlers/abstract-job-handler.ts b/server/core/lib/runners/job-handlers/abstract-job-handler.ts index 55f7cd74a..047c0e73e 100644 --- a/server/core/lib/runners/job-handlers/abstract-job-handler.ts +++ b/server/core/lib/runners/job-handlers/abstract-job-handler.ts @@ -77,7 +77,7 @@ export abstract class AbstractJobHandler { const { priority, dependsOnRunnerJob } = options - logger.debug('Creating runner job', { options, ...this.lTags(options.type) }) + logger.debug('Creating runner job', { options, dependsOnRunnerJob, ...this.lTags(options.type) }) const runnerJob = new RunnerJobModel({ ...pick(options, [ 'type', 'payload', 'privatePayload' ]), diff --git a/server/core/lib/runners/job-handlers/transcription-job-handler.ts b/server/core/lib/runners/job-handlers/transcription-job-handler.ts index 2e830d75c..49bbf2bfe 100644 --- a/server/core/lib/runners/job-handlers/transcription-job-handler.ts +++ b/server/core/lib/runners/job-handlers/transcription-job-handler.ts @@ -12,7 +12,7 @@ import { onTranscriptionEnded } from '@server/lib/video-captions.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import { MVideoUUID } from '@server/types/models/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js' -import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js' +import { generateRunnerTranscodingAudioInputFileUrl } from '../runner-urls.js' import { AbstractJobHandler } from './abstract-job-handler.js' import { loadRunnerVideo } from './shared/utils.js' @@ -59,7 +59,7 @@ export class TranscriptionJobHandler extends AbstractJobHandler { if (isVideoStudioTaskIntro(t) || isVideoStudioTaskOutro(t)) { diff --git a/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts b/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts index 283ca7c30..045ae82ca 100644 --- a/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts +++ b/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts @@ -11,19 +11,20 @@ import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding.js import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding.js' import { removeAllWebVideoFiles } from '@server/lib/video-file.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' -import { MVideo } from '@server/types/models/index.js' +import { MVideoWithFile } from '@server/types/models/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js' -import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js' +import { generateRunnerTranscodingAudioInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js' import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js' import { loadRunnerVideo } from './shared/utils.js' type CreateOptions = { - video: MVideo + video: MVideoWithFile isNewVideo: boolean deleteWebVideoFiles: boolean resolution: number fps: number priority: number + separatedAudio: boolean dependsOnRunnerJob?: MRunnerJob } @@ -31,17 +32,24 @@ type CreateOptions = { export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandler { async create (options: CreateOptions) { - const { video, resolution, fps, dependsOnRunnerJob, priority } = options + const { video, resolution, fps, dependsOnRunnerJob, separatedAudio, priority } = options const jobUUID = buildUUID() + const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles() + const payload: RunnerJobVODHLSTranscodingPayload = { input: { - videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid) + videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid), + + separatedAudioFileUrl: separatedAudioFile + ? [ generateRunnerTranscodingAudioInputFileUrl(jobUUID, video.uuid) ] + : [] }, output: { resolution, - fps + fps, + separatedAudio } } diff --git a/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts b/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts index 081995a48..3be730662 100644 --- a/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts +++ b/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts @@ -8,14 +8,14 @@ import { import { buildUUID } from '@peertube/peertube-node-utils' import { logger } from '@server/helpers/logger.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' -import { MVideo } from '@server/types/models/index.js' +import { MVideoWithFile } from '@server/types/models/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js' -import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js' +import { generateRunnerTranscodingAudioInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js' import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js' import { loadRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared/utils.js' type CreateOptions = { - video: MVideo + video: MVideoWithFile isNewVideo: boolean resolution: number fps: number @@ -31,9 +31,15 @@ export class VODWebVideoTranscodingJobHandler extends AbstractVODTranscodingJobH const { video, resolution, fps, priority, dependsOnRunnerJob } = options const jobUUID = buildUUID() + const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles() + const payload: RunnerJobVODWebVideoTranscodingPayload = { input: { - videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid) + videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid), + + separatedAudioFileUrl: separatedAudioFile + ? [ generateRunnerTranscodingAudioInputFileUrl(jobUUID, video.uuid) ] + : [] }, output: { resolution, diff --git a/server/core/lib/runners/runner-urls.ts b/server/core/lib/runners/runner-urls.ts index 0c0a9966f..0819e7ebe 100644 --- a/server/core/lib/runners/runner-urls.ts +++ b/server/core/lib/runners/runner-urls.ts @@ -4,6 +4,10 @@ export function generateRunnerTranscodingVideoInputFileUrl (jobUUID: string, vid return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/max-quality' } +export function generateRunnerTranscodingAudioInputFileUrl (jobUUID: string, videoUUID: string) { + return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/max-quality/audio' +} + export function generateRunnerTranscodingVideoPreviewFileUrl (jobUUID: string, videoUUID: string) { return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/previews/max-quality' } diff --git a/server/core/lib/thumbnail.ts b/server/core/lib/thumbnail.ts index 3fee1f313..99ac80232 100644 --- a/server/core/lib/thumbnail.ts +++ b/server/core/lib/thumbnail.ts @@ -1,5 +1,10 @@ +import { ThumbnailType, ThumbnailType_Type, VideoFileStream } from '@peertube/peertube-models' +import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import Bluebird from 'bluebird' +import { FfprobeData } from 'fluent-ffmpeg' +import { remove } from 'fs-extra/esm' import { join } from 'path' -import { ThumbnailType, ThumbnailType_Type } from '@peertube/peertube-models' import { generateImageFilename } from '../helpers/image-utils.js' import { CONFIG } from '../initializers/config.js' import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants.js' @@ -9,17 +14,12 @@ import { MThumbnail } from '../types/models/video/thumbnail.js' import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js' import { VideoPathManager } from './video-path-manager.js' import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process.js' -import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js' -import { logger, loggerTagsFactory } from '@server/helpers/logger.js' -import { remove } from 'fs-extra/esm' -import { FfprobeData } from 'fluent-ffmpeg' -import Bluebird from 'bluebird' const lTags = loggerTagsFactory('thumbnail') type ImageSize = { height?: number, width?: number } -function updateLocalPlaylistMiniatureFromExisting (options: { +export function updateLocalPlaylistMiniatureFromExisting (options: { inputPath: string playlist: MVideoPlaylistThumbnail automaticallyGenerated: boolean @@ -46,7 +46,7 @@ function updateLocalPlaylistMiniatureFromExisting (options: { }) } -function updateRemotePlaylistMiniatureFromUrl (options: { +export function updateRemotePlaylistMiniatureFromUrl (options: { downloadUrl: string playlist: MVideoPlaylistThumbnail size?: ImageSize @@ -67,7 +67,9 @@ function updateRemotePlaylistMiniatureFromUrl (options: { return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true }) } -function updateLocalVideoMiniatureFromExisting (options: { +// --------------------------------------------------------------------------- + +export function updateLocalVideoMiniatureFromExisting (options: { inputPath: string video: MVideoThumbnail type: ThumbnailType_Type @@ -96,7 +98,7 @@ function updateLocalVideoMiniatureFromExisting (options: { } // Returns thumbnail models sorted by their size (height) in descendent order (biggest first) -function generateLocalVideoMiniature (options: { +export function generateLocalVideoMiniature (options: { video: MVideoThumbnail videoFile: MVideoFile types: ThumbnailType_Type[] @@ -163,7 +165,7 @@ function generateLocalVideoMiniature (options: { // --------------------------------------------------------------------------- -function updateLocalVideoMiniatureFromUrl (options: { +export function updateLocalVideoMiniatureFromUrl (options: { downloadUrl: string video: MVideoThumbnail type: ThumbnailType_Type @@ -195,7 +197,7 @@ function updateLocalVideoMiniatureFromUrl (options: { return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true }) } -function updateRemoteVideoThumbnail (options: { +export function updateRemoteVideoThumbnail (options: { fileUrl: string video: MVideoThumbnail type: ThumbnailType_Type @@ -223,7 +225,7 @@ function updateRemoteVideoThumbnail (options: { // --------------------------------------------------------------------------- -async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: FfprobeData) { +export async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: FfprobeData) { const thumbnailsToGenerate: ThumbnailType_Type[] = [] if (video.getMiniature().automaticallyGenerated === true) { @@ -236,7 +238,7 @@ async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: const models = await generateLocalVideoMiniature({ video, - videoFile: video.getMaxQualityFile(), + videoFile: video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO), ffprobe, types: thumbnailsToGenerate }) @@ -246,18 +248,6 @@ async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: } } -// --------------------------------------------------------------------------- - -export { - generateLocalVideoMiniature, - regenerateMiniaturesIfNeeded, - updateLocalVideoMiniatureFromUrl, - updateLocalVideoMiniatureFromExisting, - updateRemoteVideoThumbnail, - updateRemotePlaylistMiniatureFromUrl, - updateLocalPlaylistMiniatureFromExisting -} - // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- diff --git a/server/core/lib/transcoding/hls-transcoding.ts b/server/core/lib/transcoding/hls-transcoding.ts index fcb358330..81dcb9a15 100644 --- a/server/core/lib/transcoding/hls-transcoding.ts +++ b/server/core/lib/transcoding/hls-transcoding.ts @@ -1,17 +1,17 @@ -import { MutexInterface } from 'async-mutex' -import { Job } from 'bullmq' -import { ensureDir, move } from 'fs-extra/esm' -import { join } from 'path' import { pick } from '@peertube/peertube-core-utils' +import { getVideoStreamDuration, HLSFromTSTranscodeOptions, HLSTranscodeOptions } from '@peertube/peertube-ffmpeg' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js' import { sequelizeTypescript } from '@server/initializers/database.js' import { MVideo } from '@server/types/models/index.js' -import { getVideoStreamDuration } from '@peertube/peertube-ffmpeg' +import { MutexInterface } from 'async-mutex' +import { Job } from 'bullmq' +import { ensureDir, move } from 'fs-extra/esm' +import { join } from 'path' import { CONFIG } from '../../initializers/config.js' import { VideoFileModel } from '../../models/video/video-file.js' import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js' -import { renameVideoFileInPlaylist, updatePlaylistAfterFileChange } from '../hls.js' +import { renameVideoFileInPlaylist, updateM3U8AndShaPlaylist } from '../hls.js' import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths.js' import { buildNewFile } from '../video-file.js' import { VideoPathManager } from '../video-path-manager.js' @@ -28,7 +28,8 @@ export async function generateHlsPlaylistResolutionFromTS (options: { }) { return generateHlsPlaylistCommon({ type: 'hls-from-ts' as 'hls-from-ts', - inputPath: options.concatenatedTsFilePath, + + videoInputPath: options.concatenatedTsFilePath, ...pick(options, [ 'video', 'resolution', 'fps', 'inputFileMutexReleaser', 'isAAC' ]) }) @@ -37,18 +38,31 @@ export async function generateHlsPlaylistResolutionFromTS (options: { // Generate an HLS playlist from an input file, and update the master playlist export function generateHlsPlaylistResolution (options: { video: MVideo + videoInputPath: string + separatedAudioInputPath: string + resolution: number fps: number copyCodecs: boolean inputFileMutexReleaser: MutexInterface.Releaser + separatedAudio: boolean job?: Job }) { return generateHlsPlaylistCommon({ type: 'hls' as 'hls', - inputPath: options.videoInputPath, - ...pick(options, [ 'video', 'resolution', 'fps', 'copyCodecs', 'inputFileMutexReleaser', 'job' ]) + ...pick(options, [ + 'videoInputPath', + 'separatedAudioInputPath', + 'video', + 'resolution', + 'fps', + 'copyCodecs', + 'separatedAudio', + 'inputFileMutexReleaser', + 'job' + ]) }) } @@ -113,7 +127,7 @@ export async function onHLSVideoFileTranscoding (options: { const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) - await updatePlaylistAfterFileChange(video, playlist) + await updateM3U8AndShaPlaylist(video, playlist) return { resolutionPlaylistPath, videoFile: savedVideoFile } } finally { @@ -121,24 +135,43 @@ export async function onHLSVideoFileTranscoding (options: { } } +// --------------------------------------------------------------------------- +// Private // --------------------------------------------------------------------------- async function generateHlsPlaylistCommon (options: { type: 'hls' | 'hls-from-ts' video: MVideo - inputPath: string + + videoInputPath: string + separatedAudioInputPath?: string resolution: number fps: number inputFileMutexReleaser: MutexInterface.Releaser + separatedAudio?: boolean + copyCodecs?: boolean isAAC?: boolean job?: Job }) { - const { type, video, inputPath, resolution, fps, copyCodecs, isAAC, job, inputFileMutexReleaser } = options + const { + type, + video, + videoInputPath, + separatedAudioInputPath, + resolution, + fps, + copyCodecs, + separatedAudio, + isAAC, + job, + inputFileMutexReleaser + } = options + const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const videoTranscodedBasePath = join(transcodeDirectory, type) @@ -150,15 +183,18 @@ async function generateHlsPlaylistCommon (options: { const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename) const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename) - const transcodeOptions = { + const transcodeOptions: HLSTranscodeOptions | HLSFromTSTranscodeOptions = { type, - inputPath, + videoInputPath, + separatedAudioInputPath, + outputPath: m3u8OutputPath, resolution, fps, copyCodecs, + separatedAudio, isAAC, diff --git a/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts b/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts index 0706d66ec..da5a4cd71 100644 --- a/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts @@ -1,20 +1,283 @@ +import { ffprobePromise } from '@peertube/peertube-ffmpeg' +import { VideoResolution } from '@peertube/peertube-models' +import { computeOutputFPS } from '@server/helpers/ffmpeg/framerate.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { CONFIG } from '@server/initializers/config.js' +import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js' +import { Hooks } from '@server/lib/plugins/hooks.js' +import { VideoPathManager } from '@server/lib/video-path-manager.js' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' +import { canDoQuickTranscode } from '../../transcoding-quick-transcode.js' +import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js' -export abstract class AbstractJobBuilder { +const lTags = loggerTagsFactory('transcoding') - abstract createOptimizeOrMergeAudioJobs (options: { +export abstract class AbstractJobBuilder

{ + + async createOptimizeOrMergeAudioJobs (options: { video: MVideoFullLight videoFile: MVideoFile isNewVideo: boolean user: MUserId videoFileAlreadyLocked: boolean - }): Promise + }) { + const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options - abstract createTranscodingJobs (options: { + let mergeOrOptimizePayload: P + let children: P[][] = [] + + const mutexReleaser = videoFileAlreadyLocked + ? () => {} + : await VideoPathManager.Instance.lockFiles(video.uuid) + + try { + await video.reload() + await videoFile.reload() + + await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => { + const probe = await ffprobePromise(videoFilePath) + const quickTranscode = await canDoQuickTranscode(videoFilePath, probe) + + let inputFPS: number + + let maxFPS: number + let maxResolution: number + + let hlsAudioAlreadyGenerated = false + + if (videoFile.isAudio()) { + inputFPS = maxFPS = VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value + maxResolution = DEFAULT_AUDIO_RESOLUTION + + mergeOrOptimizePayload = this.buildMergeAudioPayload({ + video, + isNewVideo, + inputFile: videoFile, + resolution: maxResolution, + fps: maxFPS + }) + } else { + inputFPS = videoFile.fps + maxResolution = buildOriginalFileResolution(videoFile.resolution) + maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution }) + + mergeOrOptimizePayload = this.buildOptimizePayload({ + video, + isNewVideo, + quickTranscode, + inputFile: videoFile, + resolution: maxResolution, + fps: maxFPS + }) + } + + // HLS version of max resolution + if (CONFIG.TRANSCODING.HLS.ENABLED === true) { + // We had some issues with a web video quick transcoded while producing a HLS version of it + const copyCodecs = !quickTranscode + + const hlsPayloads: P[] = [] + + hlsPayloads.push( + this.buildHLSJobPayload({ + deleteWebVideoFiles: !CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO && !CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED, + separatedAudio: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO, + + copyCodecs, + + resolution: maxResolution, + fps: maxFPS, + video, + isNewVideo + }) + ) + + if (CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO && videoFile.hasAudio()) { + hlsAudioAlreadyGenerated = true + + hlsPayloads.push( + this.buildHLSJobPayload({ + deleteWebVideoFiles: !CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED, + separatedAudio: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO, + + copyCodecs, + resolution: 0, + fps: 0, + video, + isNewVideo + }) + ) + } + + children.push(hlsPayloads) + } + + const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({ + video, + inputVideoResolution: maxResolution, + inputVideoFPS: inputFPS, + hasAudio: videoFile.hasAudio(), + isNewVideo, + hlsAudioAlreadyGenerated + }) + + children = children.concat(lowerResolutionJobPayloads) + }) + } finally { + mutexReleaser() + } + + await this.createJobs({ + parent: mergeOrOptimizePayload, + children, + user, + video + }) + } + + async createTranscodingJobs (options: { transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7 video: MVideoFullLight resolutions: number[] isNewVideo: boolean user: MUserId | null - }): Promise + }) { + const { video, transcodingType, resolutions, isNewVideo } = options + const separatedAudio = CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO + + const maxResolution = Math.max(...resolutions) + const childrenResolutions = resolutions.filter(r => r !== maxResolution) + + logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution, ...lTags(video.uuid) }) + + const inputFPS = video.getMaxFPS() + + const children = childrenResolutions.map(resolution => { + const fps = computeOutputFPS({ inputFPS, resolution }) + + if (transcodingType === 'hls') { + return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio }) + } + + if (transcodingType === 'webtorrent' || transcodingType === 'web-video') { + return this.buildWebVideoJobPayload({ video, resolution, fps, isNewVideo }) + } + + throw new Error('Unknown transcoding type') + }) + + const fps = computeOutputFPS({ inputFPS, resolution: maxResolution }) + + const parent = transcodingType === 'hls' + ? this.buildHLSJobPayload({ video, resolution: maxResolution, fps, isNewVideo, separatedAudio }) + : this.buildWebVideoJobPayload({ video, resolution: maxResolution, fps, isNewVideo }) + + // Process the last resolution after the other ones to prevent concurrency issue + // Because low resolutions use the biggest one as ffmpeg input + await this.createJobs({ video, parent, children: [ children ], user: null }) + } + + private async buildLowerResolutionJobPayloads (options: { + video: MVideoFullLight + inputVideoResolution: number + inputVideoFPS: number + hasAudio: boolean + isNewVideo: boolean + hlsAudioAlreadyGenerated: boolean + }) { + const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hlsAudioAlreadyGenerated, hasAudio } = options + + // Create transcoding jobs if there are enabled resolutions + const resolutionsEnabled = await Hooks.wrapObject( + computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }), + 'filter:transcoding.auto.resolutions-to-transcode.result', + options + ) + + logger.debug('Lower resolutions built for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) }) + + const sequentialPayloads: P[][] = [] + + for (const resolution of resolutionsEnabled) { + const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution }) + + let generateHLS = CONFIG.TRANSCODING.HLS.ENABLED + if (resolution === VideoResolution.H_NOVIDEO && hlsAudioAlreadyGenerated) generateHLS = false + + const parallelPayloads: P[] = [] + + if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) { + parallelPayloads.push( + this.buildWebVideoJobPayload({ + video, + resolution, + fps, + isNewVideo + }) + ) + } + + // Create a subsequent job to create HLS resolution that will just copy web video codecs + if (generateHLS) { + parallelPayloads.push( + this.buildHLSJobPayload({ + video, + resolution, + fps, + isNewVideo, + separatedAudio: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO, + copyCodecs: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED + }) + ) + } + + sequentialPayloads.push(parallelPayloads) + } + + return sequentialPayloads + } + + // --------------------------------------------------------------------------- + + protected abstract createJobs (options: { + video: MVideoFullLight + parent: P + children: P[][] + user: MUserId | null + }): Promise + + protected abstract buildMergeAudioPayload (options: { + video: MVideoFullLight + inputFile: MVideoFile + isNewVideo: boolean + resolution: number + fps: number + }): P + + protected abstract buildOptimizePayload (options: { + video: MVideoFullLight + isNewVideo: boolean + quickTranscode: boolean + inputFile: MVideoFile + resolution: number + fps: number + }): P + + protected abstract buildHLSJobPayload (options: { + video: MVideoFullLight + resolution: number + fps: number + isNewVideo: boolean + separatedAudio: boolean + deleteWebVideoFiles?: boolean // default false + copyCodecs?: boolean // default false + }): P + + protected abstract buildWebVideoJobPayload (options: { + video: MVideoFullLight + resolution: number + fps: number + isNewVideo: boolean + }): P + } diff --git a/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts b/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts index 0f221472f..c17a3e375 100644 --- a/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts @@ -1,14 +1,3 @@ -import Bluebird from 'bluebird' -import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' -import { logger } from '@server/helpers/logger.js' -import { CONFIG } from '@server/initializers/config.js' -import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js' -import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js' -import { Hooks } from '@server/lib/plugins/hooks.js' -import { VideoPathManager } from '@server/lib/video-path-manager.js' -import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' -import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js' -import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg' import { HLSTranscodingPayload, MergeAudioTranscodingPayload, @@ -16,83 +5,30 @@ import { OptimizeTranscodingPayload, VideoTranscodingPayload } from '@peertube/peertube-models' +import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js' +import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' +import { MUserId, MVideo } from '@server/types/models/index.js' +import Bluebird from 'bluebird' import { getTranscodingJobPriority } from '../../transcoding-priority.js' -import { canDoQuickTranscode } from '../../transcoding-quick-transcode.js' -import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js' import { AbstractJobBuilder } from './abstract-job-builder.js' -export class TranscodingJobQueueBuilder extends AbstractJobBuilder { +type Payload = + MergeAudioTranscodingPayload | + OptimizeTranscodingPayload | + NewWebVideoResolutionTranscodingPayload | + HLSTranscodingPayload - async createOptimizeOrMergeAudioJobs (options: { - video: MVideoFullLight - videoFile: MVideoFile - isNewVideo: boolean - user: MUserId - videoFileAlreadyLocked: boolean - }) { - const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options +export class TranscodingJobQueueBuilder extends AbstractJobBuilder { - let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload - let nextTranscodingSequentialJobPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = [] + protected async createJobs (options: { + video: MVideo + parent: Payload + children: Payload[][] + user: MUserId | null + }): Promise { + const { video, parent, children, user } = options - const mutexReleaser = videoFileAlreadyLocked - ? () => {} - : await VideoPathManager.Instance.lockFiles(video.uuid) - - try { - await video.reload() - await videoFile.reload() - - await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => { - const probe = await ffprobePromise(videoFilePath) - - const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe) - const hasAudio = await hasAudioStream(videoFilePath, probe) - const quickTranscode = await canDoQuickTranscode(videoFilePath, probe) - const inputFPS = videoFile.isAudio() - ? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value - : await getVideoStreamFPS(videoFilePath, probe) - - const maxResolution = await isAudioFile(videoFilePath, probe) - ? DEFAULT_AUDIO_RESOLUTION - : buildOriginalFileResolution(resolution) - - if (CONFIG.TRANSCODING.HLS.ENABLED === true) { - nextTranscodingSequentialJobPayloads.push([ - this.buildHLSJobPayload({ - deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false, - - // We had some issues with a web video quick transcoded while producing a HLS version of it - copyCodecs: !quickTranscode, - - resolution: maxResolution, - fps: computeOutputFPS({ inputFPS, resolution: maxResolution }), - videoUUID: video.uuid, - isNewVideo - }) - ]) - } - - const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({ - video, - inputVideoResolution: maxResolution, - inputVideoFPS: inputFPS, - hasAudio, - isNewVideo - }) - - nextTranscodingSequentialJobPayloads = [ ...nextTranscodingSequentialJobPayloads, ...lowerResolutionJobPayloads ] - - const hasChildren = nextTranscodingSequentialJobPayloads.length !== 0 - mergeOrOptimizePayload = videoFile.isAudio() - ? this.buildMergeAudioPayload({ videoUUID: video.uuid, isNewVideo, hasChildren }) - : this.buildOptimizePayload({ videoUUID: video.uuid, isNewVideo, quickTranscode, hasChildren }) - }) - } finally { - mutexReleaser() - } - - const nextTranscodingSequentialJobs = await Bluebird.mapSeries(nextTranscodingSequentialJobPayloads, payloads => { + const nextTranscodingSequentialJobs = await Bluebird.mapSeries(children, payloads => { return Bluebird.mapSeries(payloads, payload => { return this.buildTranscodingJob({ payload, user }) }) @@ -106,217 +42,109 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder { } } - const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: mergeOrOptimizePayload, user }) + const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: parent, user, hasChildren: !!children.length }) - await JobQueue.Instance.createSequentialJobFlow(...[ mergeOrOptimizeJob, transcodingJobBuilderJob ]) + await JobQueue.Instance.createSequentialJobFlow(mergeOrOptimizeJob, transcodingJobBuilderJob) + // transcoding-job-builder job will increase pendingTranscode await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode') } - // --------------------------------------------------------------------------- - - async createTranscodingJobs (options: { - transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7 - video: MVideoFullLight - resolutions: number[] - isNewVideo: boolean - user: MUserId | null - }) { - const { video, transcodingType, resolutions, isNewVideo } = options - - const maxResolution = Math.max(...resolutions) - const childrenResolutions = resolutions.filter(r => r !== maxResolution) - - logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution }) - - const { fps: inputFPS } = await video.probeMaxQualityFile() - - const children = childrenResolutions.map(resolution => { - const fps = computeOutputFPS({ inputFPS, resolution }) - - if (transcodingType === 'hls') { - return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo }) - } - - if (transcodingType === 'webtorrent' || transcodingType === 'web-video') { - return this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo }) - } - - throw new Error('Unknown transcoding type') - }) - - const fps = computeOutputFPS({ inputFPS, resolution: maxResolution }) - - const parent = transcodingType === 'hls' - ? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo }) - : this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo }) - - // Process the last resolution after the other ones to prevent concurrency issue - // Because low resolutions use the biggest one as ffmpeg input - await this.createTranscodingJobsWithChildren({ videoUUID: video.uuid, parent, children, user: null }) - } - - // --------------------------------------------------------------------------- - - private async createTranscodingJobsWithChildren (options: { - videoUUID: string - parent: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload) - children: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)[] - user: MUserId | null - }) { - const { videoUUID, parent, children, user } = options - - const parentJob = await this.buildTranscodingJob({ payload: parent, user }) - const childrenJobs = await Bluebird.mapSeries(children, c => this.buildTranscodingJob({ payload: c, user })) - - await JobQueue.Instance.createJobWithChildren(parentJob, childrenJobs) - - await VideoJobInfoModel.increaseOrCreate(videoUUID, 'pendingTranscode', 1 + children.length) - } - private async buildTranscodingJob (options: { payload: VideoTranscodingPayload + hasChildren?: boolean user: MUserId | null // null means we don't want priority }) { - const { user, payload } = options + const { user, payload, hasChildren = false } = options return { type: 'video-transcoding' as 'video-transcoding', priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: undefined }), - payload + payload: { ...payload, hasChildren } } } - private async buildLowerResolutionJobPayloads (options: { - video: MVideoWithFileThumbnail - inputVideoResolution: number - inputVideoFPS: number - hasAudio: boolean - isNewVideo: boolean - }) { - const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio } = options + // --------------------------------------------------------------------------- - // Create transcoding jobs if there are enabled resolutions - const resolutionsEnabled = await Hooks.wrapObject( - computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }), - 'filter:transcoding.auto.resolutions-to-transcode.result', - options - ) - - const sequentialPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = [] - - for (const resolution of resolutionsEnabled) { - const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution }) - - if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) { - const payloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[] = [ - this.buildWebVideoJobPayload({ - videoUUID: video.uuid, - resolution, - fps, - isNewVideo - }) - ] - - // Create a subsequent job to create HLS resolution that will just copy web video codecs - if (CONFIG.TRANSCODING.HLS.ENABLED) { - payloads.push( - this.buildHLSJobPayload({ - videoUUID: video.uuid, - resolution, - fps, - isNewVideo, - copyCodecs: true - }) - ) - } - - sequentialPayloads.push(payloads) - } else if (CONFIG.TRANSCODING.HLS.ENABLED) { - sequentialPayloads.push([ - this.buildHLSJobPayload({ - videoUUID: video.uuid, - resolution, - fps, - copyCodecs: false, - isNewVideo - }) - ]) - } - } - - return sequentialPayloads - } - - private buildHLSJobPayload (options: { - videoUUID: string + protected buildHLSJobPayload (options: { + video: MVideo resolution: number fps: number isNewVideo: boolean + separatedAudio: boolean deleteWebVideoFiles?: boolean // default false copyCodecs?: boolean // default false }): HLSTranscodingPayload { - const { videoUUID, resolution, fps, isNewVideo, deleteWebVideoFiles = false, copyCodecs = false } = options + const { video, resolution, fps, isNewVideo, separatedAudio, deleteWebVideoFiles = false, copyCodecs = false } = options return { type: 'new-resolution-to-hls', - videoUUID, + videoUUID: video.uuid, resolution, fps, copyCodecs, isNewVideo, + separatedAudio, deleteWebVideoFiles } } - private buildWebVideoJobPayload (options: { - videoUUID: string + protected buildWebVideoJobPayload (options: { + video: MVideo resolution: number fps: number isNewVideo: boolean }): NewWebVideoResolutionTranscodingPayload { - const { videoUUID, resolution, fps, isNewVideo } = options + const { video, resolution, fps, isNewVideo } = options return { type: 'new-resolution-to-web-video', - videoUUID, + videoUUID: video.uuid, isNewVideo, resolution, fps } } - private buildMergeAudioPayload (options: { - videoUUID: string + protected buildMergeAudioPayload (options: { + video: MVideo isNewVideo: boolean - hasChildren: boolean + fps: number + resolution: number }): MergeAudioTranscodingPayload { - const { videoUUID, isNewVideo, hasChildren } = options + const { video, isNewVideo, resolution, fps } = options return { type: 'merge-audio-to-web-video', - resolution: DEFAULT_AUDIO_RESOLUTION, - fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE, - videoUUID, - isNewVideo, - hasChildren + resolution, + fps, + videoUUID: video.uuid, + + // Will be set later + hasChildren: undefined, + + isNewVideo } } - private buildOptimizePayload (options: { - videoUUID: string + protected buildOptimizePayload (options: { + video: MVideo quickTranscode: boolean isNewVideo: boolean - hasChildren: boolean }): OptimizeTranscodingPayload { - const { videoUUID, quickTranscode, isNewVideo, hasChildren } = options + const { video, quickTranscode, isNewVideo } = options return { type: 'optimize-to-web-video', - videoUUID, + + videoUUID: video.uuid, isNewVideo, - hasChildren, + + // Will be set later + hasChildren: undefined, + quickTranscode } } + } diff --git a/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts b/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts index e00fa6dcf..2b041c20c 100644 --- a/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts @@ -1,19 +1,11 @@ -import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg' -import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' -import { logger, loggerTagsFactory } from '@server/helpers/logger.js' -import { CONFIG } from '@server/initializers/config.js' -import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js' -import { Hooks } from '@server/lib/plugins/hooks.js' import { VODAudioMergeTranscodingJobHandler, VODHLSTranscodingJobHandler, VODWebVideoTranscodingJobHandler -} from '@server/lib/runners/index.js' -import { VideoPathManager } from '@server/lib/video-path-manager.js' -import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js' -import { MRunnerJob } from '@server/types/models/runners/index.js' +} from '@server/lib/runners/job-handlers/index.js' +import { MUserId, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' +import { MRunnerJob } from '@server/types/models/runners/runner-job.js' import { getTranscodingJobPriority } from '../../transcoding-priority.js' -import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js' import { AbstractJobBuilder } from './abstract-job-builder.js' /** @@ -22,185 +14,150 @@ import { AbstractJobBuilder } from './abstract-job-builder.js' * */ -const lTags = loggerTagsFactory('transcoding') +type Payload = { + Builder: new () => VODHLSTranscodingJobHandler + options: Omit[0], 'priority'> +} | { + Builder: new () => VODAudioMergeTranscodingJobHandler + options: Omit[0], 'priority'> +} | +{ + Builder: new () => VODWebVideoTranscodingJobHandler + options: Omit[0], 'priority'> +} -export class TranscodingRunnerJobBuilder extends AbstractJobBuilder { +// eslint-disable-next-line max-len +export class TranscodingRunnerJobBuilder extends AbstractJobBuilder { - async createOptimizeOrMergeAudioJobs (options: { - video: MVideoFullLight - videoFile: MVideoFile - isNewVideo: boolean - user: MUserId - videoFileAlreadyLocked: boolean - }) { - const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options + protected async createJobs (options: { + video: MVideo + parent: Payload + children: Payload[][] // Array of sequential jobs to create that depend on parent job + user: MUserId | null + }): Promise { + const { parent, children, user } = options - const mutexReleaser = videoFileAlreadyLocked - ? () => {} - : await VideoPathManager.Instance.lockFiles(video.uuid) + const parentJob = await this.createJob({ payload: parent, user }) - try { - await video.reload() - await videoFile.reload() + for (const parallelPayloads of children) { + let lastJob = parentJob - await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => { - const probe = await ffprobePromise(videoFilePath) - - const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe) - const hasAudio = await hasAudioStream(videoFilePath, probe) - const inputFPS = videoFile.isAudio() - ? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value - : await getVideoStreamFPS(videoFilePath, probe) - - const isAudioInput = await isAudioFile(videoFilePath, probe) - const maxResolution = isAudioInput - ? DEFAULT_AUDIO_RESOLUTION - : buildOriginalFileResolution(resolution) - - const fps = computeOutputFPS({ inputFPS, resolution: maxResolution }) - const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) - - const jobPayload = { video, resolution: maxResolution, fps, isNewVideo, priority, deleteInputFileId: videoFile.id } - - const mainRunnerJob = videoFile.isAudio() - ? await new VODAudioMergeTranscodingJobHandler().create(jobPayload) - : await new VODWebVideoTranscodingJobHandler().create(jobPayload) - - if (CONFIG.TRANSCODING.HLS.ENABLED === true) { - await new VODHLSTranscodingJobHandler().create({ - video, - deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false, - resolution: maxResolution, - fps, - isNewVideo, - dependsOnRunnerJob: mainRunnerJob, - priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) - }) - } - - await this.buildLowerResolutionJobPayloads({ - video, - inputVideoResolution: maxResolution, - inputVideoFPS: inputFPS, - hasAudio, - isNewVideo, - mainRunnerJob, + for (const parallelPayload of parallelPayloads) { + lastJob = await this.createJob({ + payload: parallelPayload, + dependsOnRunnerJob: lastJob, user }) - }) - } finally { - mutexReleaser() + } + + lastJob = undefined } } + private async createJob (options: { + payload: Payload + user: MUserId | null + dependsOnRunnerJob?: MRunnerJob + }) { + const { dependsOnRunnerJob, payload, user } = options + + const builder = new payload.Builder() + + return builder.create({ + ...(payload.options as any), // FIXME: typings + + dependsOnRunnerJob, + priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) + }) + } + // --------------------------------------------------------------------------- - async createTranscodingJobs (options: { - transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7 + protected buildHLSJobPayload (options: { video: MVideoFullLight - resolutions: number[] + resolution: number + fps: number isNewVideo: boolean - user: MUserId | null - }) { - const { video, transcodingType, resolutions, isNewVideo, user } = options + separatedAudio: boolean + deleteWebVideoFiles?: boolean // default false + copyCodecs?: boolean // default false + }): Payload { + const { video, resolution, fps, isNewVideo, separatedAudio, deleteWebVideoFiles = false } = options - const maxResolution = Math.max(...resolutions) - const { fps: inputFPS } = await video.probeMaxQualityFile() - const maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution }) - const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) + return { + Builder: VODHLSTranscodingJobHandler, - const childrenResolutions = resolutions.filter(r => r !== maxResolution) - - logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution }) - - const jobPayload = { video, resolution: maxResolution, fps: maxFPS, isNewVideo, priority, deleteInputFileId: null } - - // Process the last resolution before the other ones to prevent concurrency issue - // Because low resolutions use the biggest one as ffmpeg input - const mainJob = transcodingType === 'hls' - // eslint-disable-next-line max-len - ? await new VODHLSTranscodingJobHandler().create({ ...jobPayload, deleteWebVideoFiles: false }) - : await new VODWebVideoTranscodingJobHandler().create(jobPayload) - - for (const resolution of childrenResolutions) { - const dependsOnRunnerJob = mainJob - const fps = computeOutputFPS({ inputFPS, resolution }) - - if (transcodingType === 'hls') { - await new VODHLSTranscodingJobHandler().create({ - video, - resolution, - fps, - isNewVideo, - deleteWebVideoFiles: false, - dependsOnRunnerJob, - priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) - }) - continue + options: { + video, + resolution, + fps, + isNewVideo, + separatedAudio, + deleteWebVideoFiles } - - if (transcodingType === 'webtorrent' || transcodingType === 'web-video') { - await new VODWebVideoTranscodingJobHandler().create({ - video, - resolution, - fps, - isNewVideo, - dependsOnRunnerJob, - deleteInputFileId: null, - priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) - }) - continue - } - - throw new Error('Unknown transcoding type') } } - private async buildLowerResolutionJobPayloads (options: { - mainRunnerJob: MRunnerJob - video: MVideoWithFileThumbnail - inputVideoResolution: number - inputVideoFPS: number - hasAudio: boolean + protected buildWebVideoJobPayload (options: { + video: MVideoFullLight + resolution: number + fps: number isNewVideo: boolean - user: MUserId - }) { - const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio, mainRunnerJob, user } = options + }): Payload { + const { video, resolution, fps, isNewVideo } = options - // Create transcoding jobs if there are enabled resolutions - const resolutionsEnabled = await Hooks.wrapObject( - computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }), - 'filter:transcoding.auto.resolutions-to-transcode.result', - options - ) + return { + Builder: VODWebVideoTranscodingJobHandler, - logger.debug('Lower resolutions build for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) }) - - for (const resolution of resolutionsEnabled) { - const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution }) - - if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) { - await new VODWebVideoTranscodingJobHandler().create({ - video, - resolution, - fps, - isNewVideo, - dependsOnRunnerJob: mainRunnerJob, - deleteInputFileId: null, - priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) - }) + options: { + video, + resolution, + fps, + isNewVideo, + deleteInputFileId: null } + } + } - if (CONFIG.TRANSCODING.HLS.ENABLED) { - await new VODHLSTranscodingJobHandler().create({ - video, - resolution, - fps, - isNewVideo, - deleteWebVideoFiles: false, - dependsOnRunnerJob: mainRunnerJob, - priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) - }) + protected buildMergeAudioPayload (options: { + video: MVideoFullLight + inputFile: MVideoFile + isNewVideo: boolean + fps: number + resolution: number + }): Payload { + const { video, isNewVideo, inputFile, resolution, fps } = options + + return { + Builder: VODAudioMergeTranscodingJobHandler, + options: { + video, + resolution, + fps, + isNewVideo, + deleteInputFileId: inputFile.id + } + } + } + + protected buildOptimizePayload (options: { + video: MVideoFullLight + inputFile: MVideoFile + quickTranscode: boolean + isNewVideo: boolean + fps: number + resolution: number + }): Payload { + const { video, isNewVideo, inputFile, fps, resolution } = options + + return { + Builder: VODWebVideoTranscodingJobHandler, + options: { + video, + resolution, + fps, + isNewVideo, + deleteInputFileId: inputFile.id } } } diff --git a/server/core/lib/transcoding/web-transcoding.ts b/server/core/lib/transcoding/web-transcoding.ts index ad82eb269..3ae837bbc 100644 --- a/server/core/lib/transcoding/web-transcoding.ts +++ b/server/core/lib/transcoding/web-transcoding.ts @@ -1,5 +1,11 @@ import { buildAspectRatio } from '@peertube/peertube-core-utils' -import { TranscodeVODOptionsType, getVideoStreamDuration } from '@peertube/peertube-ffmpeg' +import { + MergeAudioTranscodeOptions, + TranscodeVODOptionsType, + VideoTranscodeOptions, + getVideoStreamDuration +} from '@peertube/peertube-ffmpeg' +import { VideoFileStream } from '@peertube/peertube-models' import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js' import { VideoModel } from '@server/models/video/video.js' @@ -21,25 +27,22 @@ import { buildOriginalFileResolution } from './transcoding-resolutions.js' // Optimize the original video file and replace it. The resolution is not changed. export async function optimizeOriginalVideofile (options: { video: MVideoFullLight - inputVideoFile: MVideoFile quickTranscode: boolean job: Job }) { - const { video, inputVideoFile, quickTranscode, job } = options + const { quickTranscode, job } = options const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' // Will be released by our transcodeVOD function once ffmpeg is ran - const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(options.video.uuid) try { - await video.reload() - await inputVideoFile.reload() + const video = await VideoModel.loadFull(options.video.id) + const inputVideoFile = video.getMaxQualityFile(VideoFileStream.VIDEO) - const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video) - - const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => { + const result = await VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile, async videoInputPath => { const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) const transcodeType: TranscodeVODOptionsType = quickTranscode @@ -53,7 +56,7 @@ export async function optimizeOriginalVideofile (options: { await buildFFmpegVOD(job).transcode({ type: transcodeType, - inputPath: videoInputPath, + videoInputPath, outputPath: videoOutputPath, inputFileMutexReleaser, @@ -89,16 +92,17 @@ export async function transcodeNewWebVideoResolution (options: { try { const video = await VideoModel.loadFull(videoArg.uuid) - const file = video.getMaxQualityFile().withVideoOrPlaylist(video) - const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => { + const result = await VideoPathManager.Instance.makeAvailableMaxQualityFiles(video, async ({ videoPath, separatedAudioPath }) => { const filename = generateWebVideoFilename(resolution, newExtname) const videoOutputPath = join(transcodeDirectory, filename) - const transcodeOptions = { - type: 'video' as 'video', + const transcodeOptions: VideoTranscodeOptions = { + type: 'video', + + videoInputPath: videoPath, + separatedAudioInputPath: separatedAudioPath, - inputPath: videoInputPath, outputPath: videoOutputPath, inputFileMutexReleaser, @@ -134,11 +138,9 @@ export async function mergeAudioVideofile (options: { try { const video = await VideoModel.loadFull(videoArg.uuid) - const inputVideoFile = video.getMinQualityFile() + const inputVideoFile = video.getMaxQualityFile(VideoFileStream.AUDIO) - const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video) - - const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => { + const result = await VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile, async audioInputPath => { const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) // If the user updates the video preview during transcoding @@ -146,15 +148,16 @@ export async function mergeAudioVideofile (options: { const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath)) await copyFile(previewPath, tmpPreviewPath) - const transcodeOptions = { - type: 'merge-audio' as 'merge-audio', + const transcodeOptions: MergeAudioTranscodeOptions = { + type: 'merge-audio', + + videoInputPath: tmpPreviewPath, + audioPath: audioInputPath, - inputPath: tmpPreviewPath, outputPath: videoOutputPath, inputFileMutexReleaser, - audioPath: audioInputPath, resolution, fps } diff --git a/server/core/lib/user-import-export/exporters/abstract-user-exporter.ts b/server/core/lib/user-import-export/exporters/abstract-user-exporter.ts index 8909a66ae..97e14d84b 100644 --- a/server/core/lib/user-import-export/exporters/abstract-user-exporter.ts +++ b/server/core/lib/user-import-export/exporters/abstract-user-exporter.ts @@ -8,7 +8,7 @@ export type ExportResult = { staticFiles: { archivePath: string - createrReadStream: () => Promise + readStreamFactory: () => Promise }[] activityPub?: ActivityPubActor | ActivityPubOrderedCollection diff --git a/server/core/lib/user-import-export/exporters/actor-exporter.ts b/server/core/lib/user-import-export/exporters/actor-exporter.ts index 12e54ca6d..7b9eb3d7f 100644 --- a/server/core/lib/user-import-export/exporters/actor-exporter.ts +++ b/server/core/lib/user-import-export/exporters/actor-exporter.ts @@ -59,7 +59,7 @@ export abstract class ActorExporter extends AbstractUserExporter { staticFiles.push({ archivePath: archivePathBuilder(image.filename), - createrReadStream: () => Promise.resolve(createReadStream(image.getPath())) + readStreamFactory: () => Promise.resolve(createReadStream(image.getPath())) }) const relativePath = join(this.relativeStaticDirPath, archivePathBuilder(image.filename)) diff --git a/server/core/lib/user-import-export/exporters/video-playlists-exporter.ts b/server/core/lib/user-import-export/exporters/video-playlists-exporter.ts index 0645cf244..9d45208dd 100644 --- a/server/core/lib/user-import-export/exporters/video-playlists-exporter.ts +++ b/server/core/lib/user-import-export/exporters/video-playlists-exporter.ts @@ -26,7 +26,7 @@ export class VideoPlaylistsExporter extends AbstractUserExporter Promise.resolve(createReadStream(thumbnail.getPath())) + readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getPath())) }) archiveFiles.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailPath(playlist, thumbnail)) diff --git a/server/core/lib/user-import-export/exporters/videos-exporter.ts b/server/core/lib/user-import-export/exporters/videos-exporter.ts index 6b4804031..7c45fb6e8 100644 --- a/server/core/lib/user-import-export/exporters/videos-exporter.ts +++ b/server/core/lib/user-import-export/exporters/videos-exporter.ts @@ -6,6 +6,7 @@ import { audiencify, getAudience } from '@server/lib/activitypub/audience.js' import { buildCreateActivity } from '@server/lib/activitypub/send/send-create.js' import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js' import { getHLSFileReadStream, getOriginalFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js' +import { muxToMergeVideoFiles } from '@server/lib/video-file.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' @@ -16,7 +17,8 @@ import { VideoSourceModel } from '@server/models/video/video-source.js' import { VideoModel } from '@server/models/video/video.js' import { MStreamingPlaylistFiles, - MThumbnail, MVideo, MVideoAP, MVideoCaption, + MThumbnail, + MVideo, MVideoAP, MVideoCaption, MVideoCaptionLanguageUrl, MVideoChapter, MVideoFile, @@ -27,7 +29,7 @@ import { MVideoSource } from '@server/types/models/video/video-source.js' import Bluebird from 'bluebird' import { createReadStream } from 'fs' import { extname, join } from 'path' -import { Readable } from 'stream' +import { PassThrough, Readable } from 'stream' import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js' export class VideosExporter extends AbstractUserExporter { @@ -89,13 +91,13 @@ export class VideosExporter extends AbstractUserExporter { // Then fetch more attributes for AP serialization const videoAP = await video.lightAPToFullAP(undefined) - const { relativePathsFromJSON, staticFiles } = await this.exportVideoFiles({ video, captions }) + const { relativePathsFromJSON, staticFiles, exportedVideoFileOrSource } = await this.exportVideoFiles({ video, captions }) return { json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }), staticFiles, relativePathsFromJSON, - activityPubOutbox: await this.exportVideoAP(videoAP, chapters) + activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource) } } @@ -250,8 +252,11 @@ export class VideosExporter extends AbstractUserExporter { // --------------------------------------------------------------------------- - private async exportVideoAP (video: MVideoAP, chapters: MVideoChapter[]): Promise> { - const videoFile = video.getMaxQualityFile() + private async exportVideoAP ( + video: MVideoAP, + chapters: MVideoChapter[], + exportedVideoFileOrSource: MVideoFile | MVideoSource + ): Promise> { const icon = video.getPreview() const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC) @@ -274,13 +279,19 @@ export class VideosExporter extends AbstractUserExporter { hasParts: buildChaptersAPHasPart(video, chapters), - attachment: this.options.withVideoFiles && videoFile + attachment: this.options.withVideoFiles && exportedVideoFileOrSource ? [ { type: 'Video' as 'Video', - url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, videoFile)), + url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, exportedVideoFileOrSource)), - ...pick(videoFile.toActivityPubObject(video), [ 'mediaType', 'height', 'size', 'fps' ]) + // FIXME: typings + ...pick((exportedVideoFileOrSource as MVideoFile & MVideoSource).toActivityPubObject(video), [ + 'mediaType', + 'height', + 'size', + 'fps' + ]) } ] : undefined @@ -298,6 +309,9 @@ export class VideosExporter extends AbstractUserExporter { const { video, captions } = options const staticFiles: ExportResult['staticFiles'] = [] + + let exportedVideoFileOrSource: MVideoFile | MVideoSource + const relativePathsFromJSON = { videoFile: null as string, thumbnail: null as string, @@ -305,32 +319,32 @@ export class VideosExporter extends AbstractUserExporter { } if (this.options.withVideoFiles) { - const source = await VideoSourceModel.loadLatest(video.id) - const maxQualityFile = video.getMaxQualityFile() + const { source, videoFile, separatedAudioFile } = await this.getArchiveVideo(video) - // Prefer using original file if possible - const file = source?.keptOriginalFilename - ? source - : maxQualityFile - - if (file) { - const videoPath = this.getArchiveVideoFilePath(video, file) + if (source || videoFile || separatedAudioFile) { + const videoPath = this.getArchiveVideoFilePath(video, source || videoFile || separatedAudioFile) staticFiles.push({ archivePath: videoPath, - createrReadStream: () => file === source + + // Prefer using original file if possible + readStreamFactory: () => source?.keptOriginalFilename ? this.generateVideoSourceReadStream(source) - : this.generateVideoFileReadStream(video, maxQualityFile) + : this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile }) }) relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, videoPath) + + exportedVideoFileOrSource = source?.keptOriginalFilename + ? source + : videoFile || separatedAudioFile } } for (const caption of captions) { staticFiles.push({ archivePath: this.getArchiveCaptionFilePath(video, caption), - createrReadStream: () => Promise.resolve(createReadStream(caption.getFSPath())) + readStreamFactory: () => Promise.resolve(createReadStream(caption.getFSPath())) }) relativePathsFromJSON.captions[caption.language] = join(this.relativeStaticDirPath, this.getArchiveCaptionFilePath(video, caption)) @@ -340,13 +354,13 @@ export class VideosExporter extends AbstractUserExporter { if (thumbnail) { staticFiles.push({ archivePath: this.getArchiveThumbnailFilePath(video, thumbnail), - createrReadStream: () => Promise.resolve(createReadStream(thumbnail.getPath())) + readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getPath())) }) relativePathsFromJSON.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailFilePath(video, thumbnail)) } - return { staticFiles, relativePathsFromJSON } + return { staticFiles, relativePathsFromJSON, exportedVideoFileOrSource } } private async generateVideoSourceReadStream (source: MVideoSource): Promise { @@ -359,7 +373,22 @@ export class VideosExporter extends AbstractUserExporter { return stream } - private async generateVideoFileReadStream (video: MVideoFullLight, videoFile: MVideoFile): Promise { + private async generateVideoFileReadStream (options: { + videoFile: MVideoFile + separatedAudioFile: MVideoFile + video: MVideoFullLight + }): Promise { + const { video, videoFile, separatedAudioFile } = options + + if (separatedAudioFile) { + const stream = new PassThrough() + + muxToMergeVideoFiles({ video, videoFiles: [ videoFile, separatedAudioFile ], output: stream }) + .catch(err => logger.error('Cannot mux video files', { err })) + + return Promise.resolve(stream) + } + if (videoFile.storage === FileStorage.FILE_SYSTEM) { return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)) } @@ -371,8 +400,18 @@ export class VideosExporter extends AbstractUserExporter { return stream } - private getArchiveVideoFilePath (video: MVideo, file: { filename?: string, keptOriginalFilename?: string }) { - return join('video-files', video.uuid + extname(file.filename || file.keptOriginalFilename)) + private async getArchiveVideo (video: MVideoFullLight) { + const source = await VideoSourceModel.loadLatest(video.id) + + const { videoFile, separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles() + + if (source?.keptOriginalFilename) return { source } + + return { videoFile, separatedAudioFile } + } + + private getArchiveVideoFilePath (video: MVideo, file: { keptOriginalFilename?: string, filename?: string }) { + return join('video-files', video.uuid + extname(file.keptOriginalFilename || file.filename)) } private getArchiveCaptionFilePath (video: MVideo, caption: MVideoCaptionLanguageUrl) { diff --git a/server/core/lib/user-import-export/user-exporter.ts b/server/core/lib/user-import-export/user-exporter.ts index e7cd1c9de..a0aa237e0 100644 --- a/server/core/lib/user-import-export/user-exporter.ts +++ b/server/core/lib/user-import-export/user-exporter.ts @@ -114,7 +114,7 @@ export class UserExporter { return new Promise(async (res, rej) => { this.archive.on('warning', err => { - logger.warn('Warning to archive a file in ' + exportModel.filename, { err }) + logger.warn('Warning to archive a file in ' + exportModel.filename, { ...lTags(), err }) }) this.archive.on('error', err => { @@ -127,7 +127,7 @@ export class UserExporter { for (const { exporter, jsonFilename } of this.buildExporters(exportModel, user)) { const { json, staticFiles, activityPub, activityPubOutbox } = await exporter.export() - logger.debug('Adding JSON file ' + jsonFilename + ' in archive ' + exportModel.filename) + logger.debug(`Adding JSON file ${jsonFilename} in archive ${exportModel.filename}`, lTags()) this.appendJSON(json, join('peertube', jsonFilename)) if (activityPub) { @@ -144,12 +144,12 @@ export class UserExporter { for (const file of staticFiles) { const archivePath = join('files', parse(jsonFilename).name, file.archivePath) - logger.debug(`Adding static file ${archivePath} in archive`) + logger.debug(`Adding static file ${archivePath} in archive`, lTags()) try { - await this.addToArchiveAndWait(await file.createrReadStream(), archivePath) + await this.addToArchiveAndWait(await file.readStreamFactory(), archivePath) } catch (err) { - logger.error(`Cannot add ${archivePath} in archive`, { err }) + logger.error(`Cannot add ${archivePath} in archive`, { err, ...lTags() }) } } } @@ -287,10 +287,14 @@ export class UserExporter { this.archive.on('entry', entryListener) + logger.error('Adding stream ' + archivePath) + // Prevent sending a stream that has an error on open resulting in a stucked archiving process stream.once('readable', () => { if (errored) return + logger.error('Readable stream ' + archivePath) + this.archive.append(stream, { name: archivePath }) }) }) diff --git a/server/core/lib/user-import-export/user-importer.ts b/server/core/lib/user-import-export/user-importer.ts index acbd6f2b5..f48344c56 100644 --- a/server/core/lib/user-import-export/user-importer.ts +++ b/server/core/lib/user-import-export/user-importer.ts @@ -73,7 +73,7 @@ export class UserImporter { importModel.resultSummary = resultSummary await saveInTransactionWithRetries(importModel) } catch (err) { - logger.error('Cannot import user archive', { toto: 'coucou', err, ...lTags() }) + logger.error('Cannot import user archive', { err, ...lTags() }) try { importModel.state = UserImportState.ERRORED diff --git a/server/core/lib/video-captions.ts b/server/core/lib/video-captions.ts index 724a2da4f..2309fdc10 100644 --- a/server/core/lib/video-captions.ts +++ b/server/core/lib/video-captions.ts @@ -1,4 +1,4 @@ -import { hasAudioStream } from '@peertube/peertube-ffmpeg' +import { VideoFileStream } from '@peertube/peertube-models' import { buildSUUID } from '@peertube/peertube-node-utils' import { AbstractTranscriber, TranscriptionModel, WhisperBuiltinModel, transcriberFactory } from '@peertube/peertube-transcription' import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js' @@ -96,25 +96,30 @@ export async function generateSubtitle (options: { inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(options.video.uuid) const video = await VideoModel.loadFull(options.video.uuid) - const file = video.getMaxQualityFile().withVideoOrPlaylist(video) + if (!video) { + logger.info('Do not process transcription, video does not exist anymore.', lTags(options.video.uuid)) + return undefined + } - await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => { - if (await hasAudioStream(videoInputPath) !== true) { - logger.info( - `Do not run transcription for ${video.uuid} in ${outputPath} because it does not contain an audio stream`, - lTags(video.uuid) - ) + const file = video.getMaxQualityFile(VideoFileStream.AUDIO) - return - } + if (!file) { + logger.info( + `Do not run transcription for ${video.uuid} in ${outputPath} because it does not contain an audio stream`, + { video, ...lTags(video.uuid) } + ) + return + } + + await VideoPathManager.Instance.makeAvailableVideoFile(file, async inputPath => { // Release input file mutex now we are going to run the command setTimeout(() => inputFileMutexReleaser(), 1000) logger.info(`Running transcription for ${video.uuid} in ${outputPath}`, lTags(video.uuid)) const transcriptFile = await transcriber.transcribe({ - mediaFilePath: videoInputPath, + mediaFilePath: inputPath, model: CONFIG.VIDEO_TRANSCRIPTION.MODEL_PATH ? await TranscriptionModel.fromPath(CONFIG.VIDEO_TRANSCRIPTION.MODEL_PATH) diff --git a/server/core/lib/video-file.ts b/server/core/lib/video-file.ts index b43176fb1..c98cc293d 100644 --- a/server/core/lib/video-file.ts +++ b/server/core/lib/video-file.ts @@ -1,16 +1,33 @@ -import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@peertube/peertube-ffmpeg' -import { FileStorage, VideoFileMetadata, VideoResolution } from '@peertube/peertube-models' +import { + FFmpegContainer, + ffprobePromise, + getVideoStreamDimensionsInfo, + getVideoStreamFPS, + hasAudioStream, + hasVideoStream, + isAudioFile +} from '@peertube/peertube-ffmpeg' +import { FileStorage, VideoFileFormatFlag, VideoFileMetadata, VideoFileStream, VideoResolution } from '@peertube/peertube-models' import { getFileSize, getLowercaseExtension } from '@peertube/peertube-node-utils' +import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/ffmpeg-options.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { doRequestAndSaveToFile, generateRequestStream } from '@server/helpers/requests.js' import { CONFIG } from '@server/initializers/config.js' -import { MIMETYPES } from '@server/initializers/constants.js' +import { MIMETYPES, REQUEST_TIMEOUTS } from '@server/initializers/constants.js' import { VideoFileModel } from '@server/models/video/video-file.js' import { VideoSourceModel } from '@server/models/video/video-source.js' import { MVideo, MVideoFile, MVideoId, MVideoWithAllFiles } from '@server/types/models/index.js' import { FfprobeData } from 'fluent-ffmpeg' import { move, remove } from 'fs-extra/esm' +import { Readable, Writable } from 'stream' import { lTags } from './object-storage/shared/index.js' -import { storeOriginalVideoFile } from './object-storage/videos.js' +import { + getHLSFileReadStream, + getWebVideoFileReadStream, + makeHLSFileAvailable, + makeWebVideoFileAvailable, + storeOriginalVideoFile +} from './object-storage/videos.js' import { generateHLSVideoFilename, generateWebVideoFilename } from './paths.js' import { VideoPathManager } from './video-path-manager.js' @@ -18,7 +35,7 @@ export async function buildNewFile (options: { path: string mode: 'web-video' | 'hls' ffprobe?: FfprobeData -}) { +}): Promise { const { path, mode, ffprobe: probeArg } = options const probe = probeArg ?? await ffprobePromise(path) @@ -27,9 +44,23 @@ export async function buildNewFile (options: { const videoFile = new VideoFileModel({ extname: getLowercaseExtension(path), size, - metadata: await buildFileMetadata(path, probe) + metadata: await buildFileMetadata(path, probe), + + streams: VideoFileStream.NONE, + + formatFlags: mode === 'web-video' + ? VideoFileFormatFlag.WEB_VIDEO + : VideoFileFormatFlag.FRAGMENTED }) + if (await hasAudioStream(path, probe)) { + videoFile.streams |= VideoFileStream.AUDIO + } + + if (await hasVideoStream(path, probe)) { + videoFile.streams |= VideoFileStream.VIDEO + } + if (await isAudioFile(path, probe)) { videoFile.fps = 0 videoFile.resolution = VideoResolution.H_NOVIDEO @@ -69,8 +100,6 @@ export async function removeHLSPlaylist (video: MVideoWithAllFiles) { } export async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number) { - logger.info('Deleting HLS file %d of %s.', fileToDeleteId, video.url, lTags(video.uuid)) - const hls = video.getHLSPlaylist() const files = hls.VideoFiles @@ -231,3 +260,134 @@ export async function saveNewOriginalFileIfNeeded (video: MVideo, videoFile: MVi } } } + +// --------------------------------------------------------------------------- + +export async function muxToMergeVideoFiles (options: { + video: MVideo + videoFiles: MVideoFile[] + output: Writable +}) { + const { video, videoFiles, output } = options + + const inputs: (string | Readable)[] = [] + const tmpDestinations: string[] = [] + + try { + for (const videoFile of videoFiles) { + if (!videoFile) continue + + const { input, isTmpDestination } = await buildMuxInput(video, videoFile) + + inputs.push(input) + + if (isTmpDestination === true) tmpDestinations.push(input) + } + + const inputsToLog = inputs.map(i => { + if (typeof i === 'string') return i + + return 'ReadableStream' + }) + + logger.info(`Muxing files for video ${video.url}`, { inputs: inputsToLog, ...lTags(video.uuid) }) + + try { + await new FFmpegContainer(getFFmpegCommandWrapperOptions('vod')).mergeInputs({ inputs, output, logError: true }) + + logger.info(`Mux ended for video ${video.url}`, { inputs: inputsToLog, ...lTags(video.uuid) }) + } catch (err) { + const message = err?.message || '' + + if (message.includes('Output stream closed')) { + logger.info(`Client aborted mux for video ${video.url}`, lTags(video.uuid)) + return + } + + logger.warn(`Cannot mux files of video ${video.url}`, { err, inputs: inputsToLog, ...lTags(video.uuid) }) + + throw err + } + + } finally { + for (const destination of tmpDestinations) { + await remove(destination) + } + } +} + +async function buildMuxInput ( + video: MVideo, + videoFile: MVideoFile +): Promise<{ input: Readable, isTmpDestination: false } | { input: string, isTmpDestination: boolean }> { + + // --------------------------------------------------------------------------- + // Remote + // --------------------------------------------------------------------------- + + if (video.remote === true) { + const timeout = REQUEST_TIMEOUTS.VIDEO_FILE + + const videoSizeKB = videoFile.size / 1000 + const bodyKBLimit = videoSizeKB + 0.1 * videoSizeKB + + // FFmpeg doesn't support multiple input streams, so download the audio file on disk directly + if (videoFile.isAudio()) { + const destination = VideoPathManager.Instance.buildTMPDestination(videoFile.filename) + + // > 1GB + if (bodyKBLimit > 1000 * 1000) { + throw new Error('Cannot download remote video file > 1GB') + } + + await doRequestAndSaveToFile(videoFile.fileUrl, destination, { timeout, bodyKBLimit }) + + return { input: destination, isTmpDestination: true } + } + + return { input: generateRequestStream(videoFile.fileUrl, { timeout, bodyKBLimit }), isTmpDestination: false } + } + + // --------------------------------------------------------------------------- + // Local on FS + // --------------------------------------------------------------------------- + + if (videoFile.storage === FileStorage.FILE_SYSTEM) { + return { input: VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile), isTmpDestination: false } + } + + // --------------------------------------------------------------------------- + // Local on object storage + // --------------------------------------------------------------------------- + + // FFmpeg doesn't support multiple input streams, so download the audio file on disk directly + if (videoFile.hasAudio() && !videoFile.hasVideo()) { + const destination = VideoPathManager.Instance.buildTMPDestination(videoFile.filename) + + if (videoFile.isHLS()) { + await makeHLSFileAvailable(video.getHLSPlaylist(), videoFile.filename, destination) + } else { + await makeWebVideoFileAvailable(videoFile.filename, destination) + } + + return { input: destination, isTmpDestination: true } + } + + if (videoFile.isHLS()) { + const { stream } = await getHLSFileReadStream({ + playlist: video.getHLSPlaylist().withVideo(video), + filename: videoFile.filename, + rangeHeader: undefined + }) + + return { input: stream, isTmpDestination: false } + } + + // Web video + const { stream } = await getWebVideoFileReadStream({ + filename: videoFile.filename, + rangeHeader: undefined + }) + + return { input: stream, isTmpDestination: false } +} diff --git a/server/core/lib/video-path-manager.ts b/server/core/lib/video-path-manager.ts index eb76585ad..7bdacc92e 100644 --- a/server/core/lib/video-path-manager.ts +++ b/server/core/lib/video-path-manager.ts @@ -1,5 +1,6 @@ import { FileStorage } from '@peertube/peertube-models' import { buildUUID } from '@peertube/peertube-node-utils' +import { Awaitable } from '@peertube/peertube-typescript-utils' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { extractVideo } from '@server/helpers/video.js' import { CONFIG } from '@server/initializers/config.js' @@ -9,7 +10,8 @@ import { MVideo, MVideoFile, MVideoFileStreamingPlaylistVideo, - MVideoFileVideo + MVideoFileVideo, + MVideoWithFile } from '@server/types/models/index.js' import { Mutex } from 'async-mutex' import { remove } from 'fs-extra/esm' @@ -18,7 +20,9 @@ import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storag import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths.js' import { isVideoInPrivateDirectory } from './video-privacy.js' -type MakeAvailableCB = (path: string) => Promise | T +type MakeAvailableCB = (path: string) => Awaitable +type MakeAvailableMultipleCB = (paths: string[]) => Awaitable +type MakeAvailableCreateMethod = { method: () => Awaitable, clean: boolean } const lTags = loggerTagsFactory('video-path-manager') @@ -66,69 +70,114 @@ class VideoPathManager { return join(DIRECTORIES.ORIGINAL_VIDEOS, filename) } - async makeAvailableVideoFile (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { - if (videoFile.storage === FileStorage.FILE_SYSTEM) { - return this.makeAvailableFactory( - () => this.getFSVideoFileOutputPath(videoFile.getVideoOrStreamingPlaylist(), videoFile), - false, - cb - ) + // --------------------------------------------------------------------------- + + async makeAvailableVideoFiles (videoFiles: (MVideoFileVideo | MVideoFileStreamingPlaylistVideo)[], cb: MakeAvailableMultipleCB) { + const createMethods: MakeAvailableCreateMethod[] = [] + + for (const videoFile of videoFiles) { + if (videoFile.storage === FileStorage.FILE_SYSTEM) { + createMethods.push({ + method: () => this.getFSVideoFileOutputPath(videoFile.getVideoOrStreamingPlaylist(), videoFile), + clean: false + }) + + continue + } + + const destination = this.buildTMPDestination(videoFile.filename) + + if (videoFile.isHLS()) { + const playlist = (videoFile as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist + + createMethods.push({ + method: () => makeHLSFileAvailable(playlist, videoFile.filename, destination), + clean: true + }) + } else { + createMethods.push({ + method: () => makeWebVideoFileAvailable(videoFile.filename, destination), + clean: true + }) + } } - const destination = this.buildTMPDestination(videoFile.filename) - - if (videoFile.isHLS()) { - const playlist = (videoFile as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist - - return this.makeAvailableFactory( - () => makeHLSFileAvailable(playlist, videoFile.filename, destination), - true, - cb - ) - } - - return this.makeAvailableFactory( - () => makeWebVideoFileAvailable(videoFile.filename, destination), - true, - cb - ) + return this.makeAvailableFactory({ createMethods, cbContext: cb }) } + async makeAvailableVideoFile (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { + return this.makeAvailableVideoFiles([ videoFile ], paths => cb(paths[0])) + } + + async makeAvailableMaxQualityFiles ( + video: MVideoWithFile, + cb: (options: { videoPath: string, separatedAudioPath: string }) => Awaitable + ) { + const { videoFile, separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles() + + const files = [ videoFile ] + if (separatedAudioFile) files.push(separatedAudioFile) + + return this.makeAvailableVideoFiles(files, ([ videoPath, separatedAudioPath ]) => { + return cb({ videoPath, separatedAudioPath }) + }) + } + + // --------------------------------------------------------------------------- + async makeAvailableResolutionPlaylistFile (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { const filename = getHlsResolutionPlaylistFilename(videoFile.filename) if (videoFile.storage === FileStorage.FILE_SYSTEM) { - return this.makeAvailableFactory( - () => join(getHLSDirectory(videoFile.getVideo()), filename), - false, - cb - ) + return this.makeAvailableFactory({ + createMethods: [ + { + method: () => join(getHLSDirectory(videoFile.getVideo()), filename), + clean: false + } + ], + cbContext: paths => cb(paths[0]) + }) } const playlist = videoFile.VideoStreamingPlaylist - return this.makeAvailableFactory( - () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)), - true, - cb - ) + return this.makeAvailableFactory({ + createMethods: [ + { + method: () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)), + clean: true + } + ], + cbContext: paths => cb(paths[0]) + }) } async makeAvailablePlaylistFile (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB) { if (playlist.storage === FileStorage.FILE_SYSTEM) { - return this.makeAvailableFactory( - () => join(getHLSDirectory(playlist.Video), filename), - false, - cb - ) + return this.makeAvailableFactory({ + createMethods: [ + { + method: () => join(getHLSDirectory(playlist.Video), filename), + clean: false + } + ], + cbContext: paths => cb(paths[0]) + }) } - return this.makeAvailableFactory( - () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)), - true, - cb - ) + return this.makeAvailableFactory({ + createMethods: [ + { + method: () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)), + clean: true + } + ], + cbContext: paths => cb(paths[0]) + }) } + // --------------------------------------------------------------------------- + async lockFiles (videoUUID: string) { if (!this.videoFileMutexStore.has(videoUUID)) { this.videoFileMutexStore.set(videoUUID, new Mutex()) @@ -150,26 +199,50 @@ class VideoPathManager { logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID)) } - private async makeAvailableFactory (method: () => Promise | string, clean: boolean, cb: MakeAvailableCB) { + private async makeAvailableFactory (options: { + createMethods: MakeAvailableCreateMethod[] + cbContext: MakeAvailableMultipleCB + }) { + const { cbContext, createMethods } = options + let result: T - const destination = await method() + const created: { destination: string, clean: boolean }[] = [] + + const cleanup = async () => { + for (const { destination, clean } of created) { + if (!destination || !clean) continue + + try { + await remove(destination) + } catch (err) { + logger.error('Cannot remove ' + destination, { err }) + } + } + } + + for (const { method, clean } of createMethods) { + created.push({ + destination: await method(), + clean + }) + } try { - result = await cb(destination) + result = await cbContext(created.map(c => c.destination)) } catch (err) { - if (destination && clean) await remove(destination) + await cleanup() + throw err } - if (clean) await remove(destination) + await cleanup() return result } - private buildTMPDestination (filename: string) { + buildTMPDestination (filename: string) { return join(CONFIG.STORAGE.TMP_DIR, buildUUID() + extname(filename)) - } static get Instance () { diff --git a/server/core/lib/video-studio.ts b/server/core/lib/video-studio.ts index e97ac570d..9d53b6575 100644 --- a/server/core/lib/video-studio.ts +++ b/server/core/lib/video-studio.ts @@ -5,7 +5,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent.js' import { CONFIG } from '@server/initializers/config.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js' -import { MUser, MVideo, MVideoFile, MVideoFullLight, MVideoWithAllFiles } from '@server/types/models/index.js' +import { MUser, MVideoFile, MVideoFullLight, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models/index.js' import { move, remove } from 'fs-extra/esm' import { join } from 'path' import { JobQueue } from './job-queue/index.js' @@ -31,7 +31,7 @@ export function getStudioTaskFilePath (filename: string) { } export async function safeCleanupStudioTMPFiles (tasks: VideoStudioTaskPayload[]) { - logger.info('Removing studio task files', { tasks, ...lTags() }) + logger.info('Removing TMP studio task files', { tasks, ...lTags() }) for (const task of tasks) { try { @@ -64,13 +64,13 @@ export async function approximateIntroOutroAdditionalSize ( additionalDuration += await getVideoStreamDuration(filePath) } - return (video.getMaxQualityFile().size / video.duration) * additionalDuration + return (video.getMaxQualityBytes() / video.duration) * additionalDuration } // --------------------------------------------------------------------------- export async function createVideoStudioJob (options: { - video: MVideo + video: MVideoWithFile user: MUser payload: VideoStudioEditionPayload }) { diff --git a/server/core/middlewares/validators/videos/video-files.ts b/server/core/middlewares/validators/videos/video-files.ts index 0a14fa134..d5d743f59 100644 --- a/server/core/middlewares/validators/videos/video-files.ts +++ b/server/core/middlewares/validators/videos/video-files.ts @@ -1,11 +1,11 @@ -import express from 'express' -import { param } from 'express-validator' +import { HttpStatusCode, VideoResolution } from '@peertube/peertube-models' import { isIdValid } from '@server/helpers/custom-validators/misc.js' import { MVideo } from '@server/types/models/index.js' -import { HttpStatusCode } from '@peertube/peertube-models' +import express from 'express' +import { param } from 'express-validator' import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' -const videoFilesDeleteWebVideoValidator = [ +export const videoFilesDeleteWebVideoValidator = [ isValidVideoIdParam('id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -34,7 +34,7 @@ const videoFilesDeleteWebVideoValidator = [ } ] -const videoFilesDeleteWebVideoFileValidator = [ +export const videoFilesDeleteWebVideoFileValidator = [ isValidVideoIdParam('id'), param('videoFileId') @@ -69,7 +69,7 @@ const videoFilesDeleteWebVideoFileValidator = [ // --------------------------------------------------------------------------- -const videoFilesDeleteHLSValidator = [ +export const videoFilesDeleteHLSValidator = [ isValidVideoIdParam('id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -98,7 +98,7 @@ const videoFilesDeleteHLSValidator = [ } ] -const videoFilesDeleteHLSFileValidator = [ +export const videoFilesDeleteHLSFileValidator = [ isValidVideoIdParam('id'), param('videoFileId') @@ -112,15 +112,19 @@ const videoFilesDeleteHLSFileValidator = [ if (!checkLocalVideo(video, res)) return - if (!video.getHLSPlaylist()) { + const hls = video.getHLSPlaylist() + + if (!hls) { return res.fail({ status: HttpStatusCode.BAD_REQUEST_400, message: 'This video does not have HLS files' }) } - const hlsFiles = video.getHLSPlaylist().VideoFiles - if (!hlsFiles.find(f => f.id === +req.params.videoFileId)) { + const hlsFiles = hls.VideoFiles + const file = hlsFiles.find(f => f.id === +req.params.videoFileId) + + if (!file) { return res.fail({ status: HttpStatusCode.NOT_FOUND_404, message: 'This HLS playlist does not have this file id' @@ -135,18 +139,19 @@ const videoFilesDeleteHLSFileValidator = [ }) } + if (hls.hasAudioAndVideoSplitted() && file.resolution === VideoResolution.H_NOVIDEO) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'Cannot delete audio file of HLS playlist with splitted audio/video. Delete all the videos first' + }) + } + return next() } ] -export { - videoFilesDeleteWebVideoValidator, - videoFilesDeleteWebVideoFileValidator, - - videoFilesDeleteHLSValidator, - videoFilesDeleteHLSFileValidator -} - +// --------------------------------------------------------------------------- +// Private // --------------------------------------------------------------------------- function checkLocalVideo (video: MVideo, res: express.Response) { diff --git a/server/core/middlewares/validators/videos/video-ownership-changes.ts b/server/core/middlewares/validators/videos/video-ownership-changes.ts index 0cdb077b3..b540675ff 100644 --- a/server/core/middlewares/validators/videos/video-ownership-changes.ts +++ b/server/core/middlewares/validators/videos/video-ownership-changes.ts @@ -1,10 +1,10 @@ -import express from 'express' -import { param } from 'express-validator' +import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models' import { isIdValid } from '@server/helpers/custom-validators/misc.js' import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership.js' import { AccountModel } from '@server/models/account/account.js' import { MVideoWithAllFiles } from '@server/types/models/index.js' -import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models' +import express from 'express' +import { param } from 'express-validator' import { areValidationErrors, checkUserCanManageVideo, @@ -15,7 +15,7 @@ import { isValidVideoIdParam } from '../shared/index.js' -const videosChangeOwnershipValidator = [ +export const videosChangeOwnershipValidator = [ isValidVideoIdParam('videoId'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -36,7 +36,7 @@ const videosChangeOwnershipValidator = [ } ] -const videosTerminateChangeOwnershipValidator = [ +export const videosTerminateChangeOwnershipValidator = [ param('id') .custom(isIdValid), @@ -61,7 +61,7 @@ const videosTerminateChangeOwnershipValidator = [ } ] -const videosAcceptChangeOwnershipValidator = [ +export const videosAcceptChangeOwnershipValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { const body = req.body as VideoChangeOwnershipAccept if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return @@ -76,12 +76,8 @@ const videosAcceptChangeOwnershipValidator = [ } ] -export { - videosChangeOwnershipValidator, - videosTerminateChangeOwnershipValidator, - videosAcceptChangeOwnershipValidator -} - +// --------------------------------------------------------------------------- +// Private // --------------------------------------------------------------------------- async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response): Promise { @@ -101,7 +97,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response) const user = res.locals.oauth.token.User - if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false + if (!await checkUserQuota(user, video.getMaxQualityBytes(), res)) return false return true } diff --git a/server/core/middlewares/validators/videos/videos.ts b/server/core/middlewares/validators/videos/videos.ts index 5c2f90d0c..915c77843 100644 --- a/server/core/middlewares/validators/videos/videos.ts +++ b/server/core/middlewares/validators/videos/videos.ts @@ -9,11 +9,14 @@ import express from 'express' import { ValidationChain, body, param, query } from 'express-validator' import { exists, + hasArrayLength, isBooleanValid, isDateValid, isFileValid, isIdValid, + isNotEmptyIntArray, toBooleanOrNull, + toIntArray, toIntOrNull, toValueOrNull } from '../../../helpers/custom-validators/misc.js' @@ -52,8 +55,9 @@ import { isValidVideoPasswordHeader } from '../shared/index.js' import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js' +import { VideoLoadType } from '@server/lib/model-loaders/video.js' -const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ +export const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ body('videofile') .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null })) .withMessage('Should have a file'), @@ -92,7 +96,7 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ /** * Gets called after the last PUT request */ -const videosAddResumableValidator = [ +export const videosAddResumableValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { const user = res.locals.oauth.token.User const file = buildUploadXFile(req.body as express.CustomUploadXFile) @@ -130,7 +134,7 @@ const videosAddResumableValidator = [ * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts * */ -const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ +export const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ body('filename') .custom(isVideoSourceFilenameValid), body('name') @@ -175,7 +179,7 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ } ]) -const videosUpdateValidator = getCommonVideoEditAttributes().concat([ +export const videosUpdateValidator = getCommonVideoEditAttributes().concat([ isValidVideoIdParam('id'), body('name') @@ -215,7 +219,7 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ } ]) -async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) { +export async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) { const video = getVideoWithAttributes(res) // Anybody can watch local videos @@ -244,7 +248,8 @@ async function checkVideoFollowConstraints (req: express.Request, res: express.R }) } -const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes') => { +type FetchType = Extract +export const videosCustomGetValidator = (fetchType: FetchType) => { return [ isValidVideoIdParam('id'), @@ -266,9 +271,9 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video-and ] } -const videosGetValidator = videosCustomGetValidator('all') +export const videosGetValidator = videosCustomGetValidator('all') -const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ +export const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ isValidVideoIdParam('id'), param('videoFileId') @@ -282,7 +287,7 @@ const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ } ]) -const videosDownloadValidator = [ +export const videosDownloadValidator = [ isValidVideoIdParam('id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -297,7 +302,20 @@ const videosDownloadValidator = [ } ] -const videosRemoveValidator = [ +export const videosGenerateDownloadValidator = [ + query('videoFileIds') + .customSanitizer(toIntArray) + .custom(isNotEmptyIntArray) + .custom(v => hasArrayLength(v, { max: 2 })), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] + +export const videosRemoveValidator = [ isValidVideoIdParam('id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -311,7 +329,7 @@ const videosRemoveValidator = [ } ] -const videosOverviewValidator = [ +export const videosOverviewValidator = [ query('page') .optional() .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }), @@ -323,7 +341,7 @@ const videosOverviewValidator = [ } ] -function getCommonVideoEditAttributes () { +export function getCommonVideoEditAttributes () { return [ body('thumbnailfile') .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage( @@ -406,7 +424,7 @@ function getCommonVideoEditAttributes () { ] as (ValidationChain | ExpressPromiseHandler)[] } -const commonVideosFiltersValidator = [ +export const commonVideosFiltersValidator = [ query('categoryOneOf') .optional() .customSanitizer(arrayify) @@ -508,23 +526,7 @@ const commonVideosFiltersValidator = [ ] // --------------------------------------------------------------------------- - -export { - checkVideoFollowConstraints, - commonVideosFiltersValidator, - getCommonVideoEditAttributes, - videoFileMetadataGetValidator, - videosAddLegacyValidator, - videosAddResumableInitValidator, - videosAddResumableValidator, - videosCustomGetValidator, - videosDownloadValidator, - videosGetValidator, - videosOverviewValidator, - videosRemoveValidator, - videosUpdateValidator -} - +// Private // --------------------------------------------------------------------------- function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) { diff --git a/server/core/models/user/user-export.ts b/server/core/models/user/user-export.ts index 26f693aac..d791d893a 100644 --- a/server/core/models/user/user-export.ts +++ b/server/core/models/user/user-export.ts @@ -3,7 +3,7 @@ import { logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' import { JWT_TOKEN_USER_EXPORT_FILE_LIFETIME, - STATIC_DOWNLOAD_PATHS, + DOWNLOAD_PATHS, USER_EXPORT_FILE_PREFIX, USER_EXPORT_STATES, WEBSERVER @@ -203,7 +203,7 @@ export class UserExportModel extends SequelizeModel { getFileDownloadUrl () { if (this.state !== UserExportState.COMPLETED) return null - return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT() + return WEBSERVER.URL + join(DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT() } // --------------------------------------------------------------------------- diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index c5f254f17..11d15fabc 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -249,7 +249,10 @@ export function videoFilesModelToFormattedJSON ( fileUrl: videoFile.getFileUrl(video), fileDownloadUrl: videoFile.getFileDownloadUrl(video), - metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile) + metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile), + + hasAudio: videoFile.hasAudio(), + hasVideo: videoFile.hasVideo() } }) } diff --git a/server/core/models/video/sql/video/shared/video-table-attributes.ts b/server/core/models/video/sql/video/shared/video-table-attributes.ts index fe84c5d33..61908fd94 100644 --- a/server/core/models/video/sql/video/shared/video-table-attributes.ts +++ b/server/core/models/video/sql/video/shared/video-table-attributes.ts @@ -91,6 +91,8 @@ export class VideoTableAttributes { 'videoId', 'width', 'height', + 'formatFlags', + 'streams', 'storage' ] } diff --git a/server/core/models/video/video-file.ts b/server/core/models/video/video-file.ts index a26269451..02927b892 100644 --- a/server/core/models/video/video-file.ts +++ b/server/core/models/video/video-file.ts @@ -1,4 +1,13 @@ -import { ActivityVideoUrlObject, FileStorage, VideoResolution, type FileStorageType } from '@peertube/peertube-models' +import { + ActivityVideoUrlObject, + FileStorage, + type FileStorageType, + VideoFileFormatFlag, + type VideoFileFormatFlagType, + VideoFileStream, + type VideoFileStreamType, + VideoResolution +} from '@peertube/peertube-models' import { logger } from '@server/helpers/logger.js' import { extractVideo } from '@server/helpers/video.js' import { CONFIG } from '@server/initializers/config.js' @@ -39,10 +48,10 @@ import { isVideoFileSizeValid } from '../../helpers/custom-validators/videos.js' import { + DOWNLOAD_PATHS, LAZY_STATIC_PATHS, MEMOIZE_LENGTH, MEMOIZE_TTL, - STATIC_DOWNLOAD_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants.js' @@ -195,6 +204,14 @@ export class VideoFileModel extends SequelizeModel { @Column fps: number + @AllowNull(false) + @Column + formatFlags: VideoFileFormatFlagType + + @AllowNull(false) + @Column + streams: VideoFileStreamType + @AllowNull(true) @Column(DataType.JSONB) metadata: any @@ -503,6 +520,8 @@ export class VideoFileModel extends SequelizeModel { return extractVideo(this.getVideoOrStreamingPlaylist()) } + // --------------------------------------------------------------------------- + isAudio () { return this.resolution === VideoResolution.H_NOVIDEO } @@ -515,6 +534,14 @@ export class VideoFileModel extends SequelizeModel { return !!this.videoStreamingPlaylistId } + hasAudio () { + return (this.streams & VideoFileStream.AUDIO) === VideoFileStream.AUDIO + } + + hasVideo () { + return (this.streams & VideoFileStream.VIDEO) === VideoFileStream.VIDEO + } + // --------------------------------------------------------------------------- getObjectStorageUrl (video: MVideo) { @@ -583,8 +610,8 @@ export class VideoFileModel extends SequelizeModel { getFileDownloadUrl (video: MVideoWithHost) { const path = this.isHLS() - ? join(STATIC_DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`) - : join(STATIC_DOWNLOAD_PATHS.VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`) + ? join(DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`) + : join(DOWNLOAD_PATHS.WEB_VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`) if (video.isOwned()) return WEBSERVER.URL + path @@ -614,7 +641,7 @@ export class VideoFileModel extends SequelizeModel { getTorrentDownloadUrl () { if (!this.torrentFilename) return null - return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename) + return WEBSERVER.URL + join(DOWNLOAD_PATHS.TORRENTS, this.torrentFilename) } removeTorrent () { @@ -645,6 +672,40 @@ export class VideoFileModel extends SequelizeModel { toActivityPubObject (this: MVideoFile, video: MVideo): ActivityVideoUrlObject { const mimeType = getVideoFileMimeType(this.extname, false) + const attachment: ActivityVideoUrlObject['attachment'] = [] + + if (this.hasAudio()) { + attachment.push({ + type: 'PropertyValue', + name: 'ffprobe_codec_type', + value: 'audio' + }) + } + + if (this.hasVideo()) { + attachment.push({ + type: 'PropertyValue', + name: 'ffprobe_codec_type', + value: 'video' + }) + } + + if (this.formatFlags & VideoFileFormatFlag.FRAGMENTED) { + attachment.push({ + type: 'PropertyValue', + name: 'peertube_format_flag', + value: 'fragmented' + }) + } + + if (this.formatFlags & VideoFileFormatFlag.WEB_VIDEO) { + attachment.push({ + type: 'PropertyValue', + name: 'peertube_format_flag', + value: 'web-video' + }) + } + return { type: 'Link', mediaType: mimeType as ActivityVideoUrlObject['mediaType'], @@ -652,7 +713,8 @@ export class VideoFileModel extends SequelizeModel { height: this.height || this.resolution, width: this.width, size: this.size, - fps: this.fps + fps: this.fps, + attachment } } } diff --git a/server/core/models/video/video-source.ts b/server/core/models/video/video-source.ts index c863636e4..9c80ae148 100644 --- a/server/core/models/video/video-source.ts +++ b/server/core/models/video/video-source.ts @@ -1,7 +1,8 @@ -import { type FileStorageType, type VideoSource } from '@peertube/peertube-models' -import { STATIC_DOWNLOAD_PATHS, WEBSERVER } from '@server/initializers/constants.js' +import { ActivityVideoUrlObject, type FileStorageType, type VideoSource } from '@peertube/peertube-models' +import { DOWNLOAD_PATHS, WEBSERVER } from '@server/initializers/constants.js' +import { getVideoFileMimeType } from '@server/lib/video-file.js' import { MVideoSource } from '@server/types/models/video/video-source.js' -import { join } from 'path' +import { extname, join } from 'path' import { Transaction } from 'sequelize' import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' import { SequelizeModel, doesExist, getSort } from '../shared/index.js' @@ -118,10 +119,25 @@ export class VideoSourceModel extends SequelizeModel { getFileDownloadUrl () { if (!this.keptOriginalFilename) return null - return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE, this.keptOriginalFilename) + return WEBSERVER.URL + join(DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE, this.keptOriginalFilename) } - toFormattedJSON (): VideoSource { + toActivityPubObject (this: MVideoSource): ActivityVideoUrlObject { + const mimeType = getVideoFileMimeType(extname(this.inputFilename), false) + + return { + type: 'Link', + mediaType: mimeType as ActivityVideoUrlObject['mediaType'], + href: null, + height: this.height || this.resolution, + width: this.width, + size: this.size, + fps: this.fps, + attachment: [] + } + } + + toFormattedJSON (this: MVideoSource): VideoSource { return { filename: this.inputFilename, inputFilename: this.inputFilename, diff --git a/server/core/models/video/video-streaming-playlist.ts b/server/core/models/video/video-streaming-playlist.ts index 4cafd10f7..0f463969b 100644 --- a/server/core/models/video/video-streaming-playlist.ts +++ b/server/core/models/video/video-streaming-playlist.ts @@ -1,16 +1,18 @@ import { FileStorage, + VideoResolution, VideoStreamingPlaylistType, type FileStorageType, type VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' import { sha1 } from '@peertube/peertube-node-utils' +import { logger } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' import { getHLSPrivateFileUrl, getObjectStoragePublicFileUrl } from '@server/lib/object-storage/index.js' import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js' import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js' import { VideoFileModel } from '@server/models/video/video-file.js' -import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js' +import { MStreamingPlaylist, MStreamingPlaylistFiles, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js' import memoizee from 'memoizee' import { join } from 'path' import { Op, Transaction } from 'sequelize' @@ -147,6 +149,8 @@ export class VideoStreamingPlaylistModel extends SequelizeModel { return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked() } - getQualityFileBy (this: T, fun: (files: MVideoFile[], property: 'resolution') => MVideoFile) { - const files = this.getAllFiles() + // --------------------------------------------------------------------------- + + getMaxQualityAudioAndVideoFiles (this: T) { + const videoFile = this.getMaxQualityFile(VideoFileStream.VIDEO) + if (!videoFile) return { videoFile: undefined } + + // File also has audio, we can return it + if (videoFile.hasAudio()) return { videoFile } + + const separatedAudioFile = this.getMaxQualityFile(VideoFileStream.AUDIO) + if (!separatedAudioFile) return { videoFile } + + return { videoFile, separatedAudioFile } + } + + getMaxQualityFile ( + this: T, + streamFilter: VideoFileStreamType + ): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { + return this.getQualityFileBy(streamFilter, maxBy) + } + + getMaxQualityBytes (this: T) { + const { videoFile, separatedAudioFile } = this.getMaxQualityAudioAndVideoFiles() + + let size = videoFile.size + if (separatedAudioFile) size += separatedAudioFile.size + + return size + } + + getQualityFileBy ( + this: T, + streamFilter: VideoFileStreamType, + fun: (files: MVideoFile[], property: 'resolution') => MVideoFile + ) { + const files = this.getAllFiles().filter(f => f.streams & streamFilter) const file = fun(files, 'resolution') if (!file) return undefined @@ -1753,27 +1791,40 @@ export class VideoModel extends SequelizeModel { throw new Error('File is not associated to a video of a playlist') } - getMaxQualityFile (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { - return this.getQualityFileBy(maxBy) + // --------------------------------------------------------------------------- + + getMaxFPS () { + return this.getMaxQualityFile(VideoFileStream.VIDEO).fps } - getMinQualityFile (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { - return this.getQualityFileBy(minBy) + getMaxResolution () { + return this.getMaxQualityFile(VideoFileStream.VIDEO).resolution } - getWebVideoFile (this: T, resolution: number): MVideoFileVideo { + hasAudio () { + return !!this.getMaxQualityFile(VideoFileStream.AUDIO) + } + + // --------------------------------------------------------------------------- + + getWebVideoFileMinResolution (this: T, resolution: number): MVideoFileVideo { if (Array.isArray(this.VideoFiles) === false) return undefined - const file = this.VideoFiles.find(f => f.resolution === resolution) - if (!file) return undefined + for (const file of sortBy(this.VideoFiles, 'resolution')) { + if (file.resolution < resolution) continue - return Object.assign(file, { Video: this }) + return Object.assign(file, { Video: this }) + } + + return undefined } hasWebVideoFiles () { return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0 } + // --------------------------------------------------------------------------- + async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) { thumbnail.videoId = this.id @@ -1787,21 +1838,21 @@ export class VideoModel extends SequelizeModel { // --------------------------------------------------------------------------- - hasMiniature () { + hasMiniature (this: MVideoThumbnail) { return !!this.getMiniature() } - getMiniature () { + getMiniature (this: MVideoThumbnail) { if (Array.isArray(this.Thumbnails) === false) return undefined return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE) } - hasPreview () { + hasPreview (this: MVideoThumbnail) { return !!this.getPreview() } - getPreview () { + getPreview (this: MVideoThumbnail) { if (Array.isArray(this.Thumbnails) === false) return undefined return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW) @@ -1930,27 +1981,6 @@ export class VideoModel extends SequelizeModel { return files } - probeMaxQualityFile () { - const file = this.getMaxQualityFile() - const videoOrPlaylist = file.getVideoOrStreamingPlaylist() - - return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => { - const probe = await ffprobePromise(originalFilePath) - - const { audioStream } = await getAudioStream(originalFilePath, probe) - const hasAudio = await hasAudioStream(originalFilePath, probe) - const fps = await getVideoStreamFPS(originalFilePath, probe) - - return { - audioStream, - hasAudio, - fps, - - ...await getVideoStreamDimensionsInfo(originalFilePath, probe) - } - }) - } - getDescriptionAPIPath () { return `/api/${API_VERSION}/videos/${this.uuid}/description` } @@ -1977,6 +2007,8 @@ export class VideoModel extends SequelizeModel { .concat(toAdd) } + // --------------------------------------------------------------------------- + removeWebVideoFile (videoFile: MVideoFile, isRedundancy = false) { const filePath = isRedundancy ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) @@ -1989,6 +2021,8 @@ export class VideoModel extends SequelizeModel { promises.push(removeWebVideoObjectStorage(videoFile)) } + logger.debug(`Removing files associated to web video ${videoFile.filename}`, { videoFile, isRedundancy, ...lTags(this.uuid) }) + return Promise.all(promises) } @@ -2029,6 +2063,11 @@ export class VideoModel extends SequelizeModel { await removeHLSObjectStorage(streamingPlaylist.withVideo(this)) } } + + logger.debug( + `Removing files associated to streaming playlist of video ${this.url}`, + { streamingPlaylist, isRedundancy, ...lTags(this.uuid) } + ) } async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) { @@ -2043,6 +2082,11 @@ export class VideoModel extends SequelizeModel { await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename) await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename) } + + logger.debug( + `Removing files associated to streaming playlist video file ${videoFile.filename}`, + { streamingPlaylist, ...lTags(this.uuid) } + ) } async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) { @@ -2052,6 +2096,8 @@ export class VideoModel extends SequelizeModel { if (streamingPlaylist.storage === FileStorage.OBJECT_STORAGE) { await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename) } + + logger.debug(`Removing streaming playlist file ${filename}`, lTags(this.uuid)) } async removeOriginalFile (videoSource: MVideoSource) { @@ -2063,8 +2109,12 @@ export class VideoModel extends SequelizeModel { if (videoSource.storage === FileStorage.OBJECT_STORAGE) { await removeOriginalFileObjectStorage(videoSource) } + + logger.debug(`Removing original video file ${videoSource.keptOriginalFilename}`, lTags(this.uuid)) } + // --------------------------------------------------------------------------- + isOutdated () { if (this.isOwned()) return false diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index cd08d6f24..8db18f1fb 100644 --- a/server/core/types/models/video/video.ts +++ b/server/core/types/models/video/video.ts @@ -66,8 +66,7 @@ export type MVideoIdThumbnail = Use<'Thumbnails', MThumbnail[]> export type MVideoWithFileThumbnail = - MVideo & - Use<'VideoFiles', MVideoFile[]> & + MVideoWithFile & Use<'Thumbnails', MThumbnail[]> export type MVideoThumbnailBlacklist = diff --git a/server/scripts/migrations/peertube-6.3.ts b/server/scripts/migrations/peertube-6.3.ts new file mode 100644 index 000000000..e3e6fb0b7 --- /dev/null +++ b/server/scripts/migrations/peertube-6.3.ts @@ -0,0 +1,95 @@ +import { ffprobePromise, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' +import { VideoFileStream } from '@peertube/peertube-models' +import { initDatabaseModels } from '@server/initializers/database.js' +import { buildFileMetadata } from '@server/lib/video-file.js' +import { VideoPathManager } from '@server/lib/video-path-manager.js' +import { VideoFileModel } from '@server/models/video/video-file.js' +import { VideoModel } from '@server/models/video/video.js' +import Bluebird from 'bluebird' +import { pathExists } from 'fs-extra/esm' + +run() + .then(() => process.exit(0)) + .catch(err => { + console.error(err) + process.exit(-1) + }) + +async function run () { + console.log('## Assigning metadata information to local video files ##\n') + + await initDatabaseModels(true) + + const ids = await VideoModel.listLocalIds() + + await Bluebird.map(ids, async id => { + try { + await processVideo(id) + } catch (err) { + console.error('Cannot process video ' + id, err) + } + }, { concurrency: 5 }) + + console.log('\n## Migration finished! ##') +} + +async function processVideo (videoId: number) { + const video = await VideoModel.loadWithFiles(videoId) + if (video.isLive) return + + const files = await Promise.all(video.getAllFiles().map(f => VideoFileModel.loadWithMetadata(f.id))) + + if (!files.some(f => f.fps === -1 || !f.metadata || !f.streams || f.width === null || f.height === null)) return + + console.log('Processing video "' + video.name + '"') + + for (const file of files) { + if (!file.metadata || file.fps === -1) { + const fileWithVideoOrPlaylist = file.videoStreamingPlaylistId + ? file.withVideoOrPlaylist(video.getHLSPlaylist()) + : file.withVideoOrPlaylist(video) + + await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async path => { + if (!await pathExists(path)) { + console.error( + `Skipping processing file ${file.id} because ${path} does not exist on disk for video "${video.name}" (uuid: ${video.uuid})` + ) + return + } + + console.log(`Filling metadata field of video "${video.name}" - file ${file.id} because it is missing in database`) + + const probe = await ffprobePromise(path) + + file.metadata = await buildFileMetadata(path, probe) + file.fps = await getVideoStreamFPS(path, probe) + + await file.save() + }) + } + + if (!file.metadata) continue + + file.streams = VideoFileStream.NONE + + const videoStream = file.metadata.streams.find(s => s.codec_type === 'video') + + if (videoStream) { + file.streams |= VideoFileStream.VIDEO + + file.width = videoStream.width + file.height = videoStream.height + } else { + file.width = 0 + file.height = 0 + } + + if (file.metadata.streams.some(s => s.codec_type === 'audio')) { + file.streams |= VideoFileStream.AUDIO + } + + await file.save() + } + + console.log('Successfully processed video "' + video.name + '"') +} diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index db8420f71..b8be67a5d 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -289,6 +289,8 @@ tags: description: Video statistics - name: Video Feeds description: Server syndication feeds of videos + - name: Video Download + description: Download video files - name: Search description: | The search helps to find _videos_ or _channels_ from within the instance and beyond. @@ -318,6 +320,9 @@ x-tagGroups: - name: Static endpoints tags: - Static Video Files + - name: Download + tags: + - Video Download - name: Feeds tags: - Video Feeds @@ -449,6 +454,32 @@ paths: '404': description: not found + '/download/videos/generate/:videoId': + get: + tags: + - Video Download + summary: Download video file + description: Generate a mp4 container that contains at most 1 video stream and at most 1 audio stream. + Mainly used to merge the HLS audio only video file and the HLS video only resolution file. + parameters: + - name: videoId + in: path + required: true + description: The video id + schema: + $ref: '#/components/schemas/Video/properties/id' + - name: videoFileIds + in: query + required: true + description: streams of video files to mux in the output + schema: + type: array + items: + type: integer + - $ref: '#/components/parameters/videoFileToken' + responses: + '200': + description: successful operation '/feeds/video-comments.{format}': get: @@ -9542,6 +9573,8 @@ components: properties: enabled: type: boolean + splitAudioAndVideo: + type: boolean import: type: object properties: