From 536598cfafab1c5e24e881db1c528489f804fb6b Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 16 May 2019 16:55:34 +0200 Subject: Add audio support in upload --- .../edit-custom-config.component.html | 8 ++ .../edit-custom-config.component.ts | 1 + .../videos/+video-watch/video-watch.component.ts | 8 +- .../src/assets/player/peertube-player-manager.ts | 13 +- config/default.yaml | 2 + config/production.yaml.example | 2 + config/test.yaml | 1 + scripts/create-transcoding-job.ts | 13 +- server/assets/default-audio-background.jpg | Bin 0 -> 14048 bytes server/controllers/api/config.ts | 1 + server/controllers/api/videos/index.ts | 38 +++-- server/controllers/static.ts | 2 +- server/helpers/ffmpeg-utils.ts | 154 ++++++++++++--------- server/initializers/config.ts | 1 + server/initializers/constants.ts | 64 ++++++--- server/lib/files-cache/videos-preview-cache.ts | 2 +- server/lib/job-queue/handlers/video-file-import.ts | 4 +- server/lib/job-queue/handlers/video-import.ts | 1 + server/lib/job-queue/handlers/video-transcoding.ts | 56 ++++++-- server/lib/thumbnail.ts | 8 +- server/lib/video-transcoding.ts | 88 ++++++++---- server/models/video/thumbnail.ts | 8 +- server/models/video/video-file.ts | 5 + server/tests/api/check-params/config.ts | 1 + server/tests/api/server/config.ts | 3 + shared/extra-utils/server/config.ts | 1 + shared/models/server/custom-config.model.ts | 1 + 27 files changed, 324 insertions(+), 162 deletions(-) create mode 100644 server/assets/default-audio-background.jpg diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 637484622..44fc6dc26 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -286,6 +286,14 @@ > +
+ +
+
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 e64750713..c238a6c81 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 @@ -116,6 +116,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { enabled: null, threads: this.customConfigValidatorsService.TRANSCODING_THREADS, allowAdditionalExtensions: null, + allowAudioFiles: null, resolutions: {} }, autoBlacklist: { diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index b147b75b0..d8ba4df89 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -561,8 +561,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private flushPlayer () { // Remove player if it exists if (this.player) { - this.player.dispose() - this.player = undefined + try { + this.player.dispose() + this.player = undefined + } catch (err) { + console.error('Cannot dispose player.', err) + } } } } diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 6cdd54372..31cbc7dfd 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -117,8 +117,17 @@ export class PeertubePlayerManager { videojs(options.common.playerElement, videojsOptions, function (this: any) { const player = this - player.tech_.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options)) - player.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options)) + let alreadyFallback = false + + player.tech_.one('error', () => { + if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) + alreadyFallback = true + }) + + player.one('error', () => { + if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) + alreadyFallback = true + }) self.addContextMenu(mode, player, options.common.embedUrl) diff --git a/config/default.yaml b/config/default.yaml index 37ef4366f..9c9fd93dd 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -174,6 +174,8 @@ transcoding: enabled: true # Allow your users to upload .mkv, .mov, .avi, .flv videos allow_additional_extensions: true + # If a user uploads an audio file, PeerTube will create a video by merging the preview file and the audio file + allow_audio_files: true threads: 1 resolutions: # Only created if the original video has a higher resolution, uses more storage! 240p: false diff --git a/config/production.yaml.example b/config/production.yaml.example index f84e15670..0ab99ac45 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -188,6 +188,8 @@ transcoding: enabled: true # Allow your users to upload .mkv, .mov, .avi, .flv videos allow_additional_extensions: true + # If a user uploads an audio file, PeerTube will create a video by merging the preview file and the audio file + allow_audio_files: true threads: 1 resolutions: # Only created if the original video has a higher resolution, uses more storage! 240p: false diff --git a/config/test.yaml b/config/test.yaml index 682530840..7dabe433c 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -55,6 +55,7 @@ signup: transcoding: enabled: true allow_additional_extensions: false + allow_audio_files: false threads: 2 resolutions: 240p: true diff --git a/scripts/create-transcoding-job.ts b/scripts/create-transcoding-job.ts index 4a677eacb..2b7cb5177 100755 --- a/scripts/create-transcoding-job.ts +++ b/scripts/create-transcoding-job.ts @@ -2,6 +2,7 @@ import * as program from 'commander' import { VideoModel } from '../server/models/video/video' import { initDatabaseModels } from '../server/initializers' import { JobQueue } from '../server/lib/job-queue' +import { VideoTranscodingPayload } from '../server/lib/job-queue/handlers/video-transcoding' program .option('-v, --video [videoUUID]', 'Video UUID') @@ -31,15 +32,9 @@ async function run () { const video = await VideoModel.loadByUUIDWithFile(program['video']) if (!video) throw new Error('Video not found.') - const dataInput = { - videoUUID: video.uuid, - isNewVideo: false, - resolution: undefined - } - - if (program.resolution !== undefined) { - dataInput.resolution = program.resolution - } + const dataInput: VideoTranscodingPayload = program.resolution !== undefined + ? { type: 'new-resolution' as 'new-resolution', videoUUID: video.uuid, isNewVideo: false, resolution: program.resolution } + : { type: 'optimize' as 'optimize', videoUUID: video.uuid, isNewVideo: false } await JobQueue.Instance.init() await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput }) diff --git a/server/assets/default-audio-background.jpg b/server/assets/default-audio-background.jpg new file mode 100644 index 000000000..a19173eac Binary files /dev/null and b/server/assets/default-audio-background.jpg differ diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 40012c03b..d9ce6a153 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -255,6 +255,7 @@ function customConfig (): CustomConfig { transcoding: { enabled: CONFIG.TRANSCODING.ENABLED, allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS, + allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES, threads: CONFIG.TRANSCODING.THREADS, resolutions: { '240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ], diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 1a18a8ae8..a2a615a79 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -1,12 +1,12 @@ import * as express from 'express' import { extname, join } from 'path' -import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' +import { VideoCreate, VideoPrivacy, VideoResolution, VideoState, VideoUpdate } from '../../../../shared' import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' import { logger } from '../../../helpers/logger' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { getFormattedObjects, getServerActor } from '../../../helpers/utils' import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' -import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants' +import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants' import { changeVideoChannelShare, federateVideoIfNeeded, @@ -54,6 +54,7 @@ import { CONFIG } from '../../../initializers/config' import { sequelizeTypescript } from '../../../initializers/database' import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail' import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' +import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding' const auditLogger = auditLoggerFactory('videos') const videosRouter = express.Router() @@ -191,18 +192,19 @@ async function addVideo (req: express.Request, res: express.Response) { const video = new VideoModel(videoData) video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object - // Build the file object - const { videoFileResolution } = await getVideoFileResolution(videoPhysicalFile.path) - const fps = await getVideoFileFPS(videoPhysicalFile.path) - const videoFileData = { extname: extname(videoPhysicalFile.filename), - resolution: videoFileResolution, - size: videoPhysicalFile.size, - fps + size: videoPhysicalFile.size } const videoFile = new VideoFileModel(videoFileData) + if (!videoFile.isAudio()) { + videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path) + videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution + } else { + videoFile.resolution = DEFAULT_AUDIO_RESOLUTION + } + // Move physical file const videoDir = CONFIG.STORAGE.VIDEOS_DIR const destination = join(videoDir, video.getVideoFilename(videoFile)) @@ -279,9 +281,21 @@ async function addVideo (req: express.Request, res: express.Response) { if (video.state === VideoState.TO_TRANSCODE) { // Put uuid because we don't have id auto incremented for now - const dataInput = { - videoUUID: videoCreated.uuid, - isNewVideo: true + let dataInput: VideoTranscodingPayload + + if (videoFile.isAudio()) { + dataInput = { + type: 'merge-audio' as 'merge-audio', + resolution: DEFAULT_AUDIO_RESOLUTION, + videoUUID: videoCreated.uuid, + isNewVideo: true + } + } else { + dataInput = { + type: 'optimize' as 'optimize', + videoUUID: videoCreated.uuid, + isNewVideo: true + } } await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput }) diff --git a/server/controllers/static.ts b/server/controllers/static.ts index 05019fcc2..d57dba6ce 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -181,7 +181,7 @@ async function getVideoCaption (req: express.Request, res: express.Response) { return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE }) } -async function generateNodeinfo (req: express.Request, res: express.Response, next: express.NextFunction) { +async function generateNodeinfo (req: express.Request, res: express.Response) { const { totalVideos } = await VideoModel.getStats() const { totalLocalVideoComments } = await VideoCommentModel.getStats() const { totalUsers } = await UserModel.getStats() diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 2fdf34cb7..c180da832 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -117,37 +117,50 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima } } -type TranscodeOptions = { +type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' + +interface BaseTranscodeOptions { + type: TranscodeOptionsType inputPath: string outputPath: string resolution: VideoResolution isPortraitMode?: boolean - doQuickTranscode?: Boolean +} - hlsPlaylist?: { +interface HLSTranscodeOptions extends BaseTranscodeOptions { + type: 'hls' + hlsPlaylist: { videoFilename: string } } +interface QuickTranscodeOptions extends BaseTranscodeOptions { + type: 'quick-transcode' +} + +interface VideoTranscodeOptions extends BaseTranscodeOptions { + type: 'video' +} + +interface MergeAudioTranscodeOptions extends BaseTranscodeOptions { + type: 'merge-audio' + audioPath: string +} + +type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions + function transcode (options: TranscodeOptions) { return new Promise(async (res, rej) => { try { let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) .output(options.outputPath) - if (options.doQuickTranscode) { - if (options.hlsPlaylist) { - throw(Error("Quick transcode and HLS can't be used at the same time")) - } - - command - .format('mp4') - .addOption('-c:v copy') - .addOption('-c:a copy') - .outputOption('-map_metadata -1') // strip all metadata - .outputOption('-movflags faststart') - } else if (options.hlsPlaylist) { + if (options.type === 'quick-transcode') { + command = await buildQuickTranscodeCommand(command) + } else if (options.type === 'hls') { command = await buildHLSCommand(command, options) + } else if (options.type === 'merge-audio') { + command = await buildAudioMergeCommand(command, options) } else { command = await buildx264Command(command, options) } @@ -163,7 +176,7 @@ function transcode (options: TranscodeOptions) { return rej(err) }) .on('end', () => { - return onTranscodingSuccess(options) + return fixHLSPlaylistIfNeeded(options) .then(() => res()) .catch(err => rej(err)) }) @@ -205,6 +218,8 @@ export { getVideoFileResolution, getDurationFromVideoFile, generateImageFromVideoFile, + TranscodeOptions, + TranscodeOptionsType, transcode, getVideoFileFPS, computeResolutionsToTranscode, @@ -215,7 +230,7 @@ export { // --------------------------------------------------------------------------- -async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { +async function buildx264Command (command: ffmpeg.FfmpegCommand, options: VideoTranscodeOptions) { let fps = await getVideoFileFPS(options.inputPath) // On small/medium resolutions, limit FPS if ( @@ -226,7 +241,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco fps = VIDEO_TRANSCODING_FPS.AVERAGE } - command = await presetH264(command, options.resolution, fps) + command = await presetH264(command, options.inputPath, options.resolution, fps) if (options.resolution !== undefined) { // '?x720' or '720x?' for example @@ -245,7 +260,29 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco return command } -async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { +async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) { + command = command.loop(undefined) + + command = await presetH264VeryFast(command, options.audioPath, options.resolution) + + command = command.input(options.audioPath) + .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error + .outputOption('-tune stillimage') + .outputOption('-shortest') + + return command +} + +async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) { + command = await presetCopy(command) + + command = command.outputOption('-map_metadata -1') // strip all metadata + .outputOption('-movflags faststart') + + return command +} + +async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { const videoPath = getHLSVideoPath(options) command = await presetCopy(command) @@ -261,19 +298,19 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod return command } -function getHLSVideoPath (options: TranscodeOptions) { +function getHLSVideoPath (options: HLSTranscodeOptions) { return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` } -async function onTranscodingSuccess (options: TranscodeOptions) { - if (!options.hlsPlaylist) return +async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { + if (options.type !== 'hls') return - // Fix wrong mapping with some ffmpeg versions const fileContent = await readFile(options.outputPath) const videoFileName = options.hlsPlaylist.videoFilename const videoFilePath = getHLSVideoPath(options) + // Fix wrong mapping with some ffmpeg versions const newContent = fileContent.toString() .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) @@ -300,44 +337,27 @@ function getVideoStreamFromFile (path: string) { * and quality. Superfast and ultrafast will give you better * performance, but then quality is noticeably worse. */ -async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise { - let localCommand = await presetH264(command, resolution, fps) +async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) { + let localCommand = await presetH264(command, input, resolution, fps) + localCommand = localCommand.outputOption('-preset:v veryfast') - .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) + /* MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html Our target situation is closer to a livestream than a stream, since we want to reduce as much a possible the encoding burden, - altough not to the point of a livestream where there is a hard + although not to the point of a livestream where there is a hard constraint on the frames per second to be encoded. - - why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'? - Make up for most of the loss of grain and macroblocking - with less computing power. */ return localCommand } -/** - * A preset optimised for a stillimage audio video - */ -async function presetStillImageWithAudio ( - command: ffmpeg.FfmpegCommand, - resolution: VideoResolution, - fps: number -): Promise { - let localCommand = await presetH264VeryFast(command, resolution, fps) - localCommand = localCommand.outputOption('-tune stillimage') - - return localCommand -} - /** * A toolbox to play with audio */ namespace audio { - export const get = (option: ffmpeg.FfmpegCommand | string) => { + export const get = (option: string) => { // without position, ffprobe considers the last input only // we make it consider the first input only // if you pass a file path to pos, then ffprobe acts on that file directly @@ -359,11 +379,7 @@ namespace audio { return res({ absolutePath: data.format.filename }) } - if (typeof option === 'string') { - return ffmpeg.ffprobe(option, parseFfprobe) - } - - return option.ffprobe(parseFfprobe) + return ffmpeg.ffprobe(option, parseFfprobe) }) } @@ -405,7 +421,7 @@ namespace audio { * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr */ -async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise { +async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) { let localCommand = command .format('mp4') .videoCodec('libx264') @@ -416,7 +432,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol .outputOption('-map_metadata -1') // strip all metadata .outputOption('-movflags faststart') - const parsedAudio = await audio.get(localCommand) + const parsedAudio = await audio.get(input) if (!parsedAudio.audioStream) { localCommand = localCommand.noAudio() @@ -425,28 +441,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol .audioCodec('libfdk_aac') .audioQuality(5) } else { - // we try to reduce the ceiling bitrate by making rough correspondances of bitrates + // we try to reduce the ceiling bitrate by making rough matches of bitrates // of course this is far from perfect, but it might save some space in the end + localCommand = localCommand.audioCodec('aac') + const audioCodecName = parsedAudio.audioStream[ 'codec_name' ] - let bitrate: number - if (audio.bitrate[ audioCodecName ]) { - localCommand = localCommand.audioCodec('aac') - bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) + if (audio.bitrate[ audioCodecName ]) { + const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) } } - // Constrained Encoding (VBV) - // https://slhck.info/video/2017/03/01/rate-control.html - // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate - const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) - localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) - - // Keyframe interval of 2 seconds for faster seeking and resolution switching. - // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html - // https://superuser.com/a/908325 - localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) + if (fps) { + // Constrained Encoding (VBV) + // https://slhck.info/video/2017/03/01/rate-control.html + // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate + const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) + localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ]) + + // Keyframe interval of 2 seconds for faster seeking and resolution switching. + // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html + // https://superuser.com/a/908325 + localCommand = localCommand.outputOption(`-g ${fps * 2}`) + } return localCommand } diff --git a/server/initializers/config.ts b/server/initializers/config.ts index 4f77e144d..4515bc804 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -140,6 +140,7 @@ const CONFIG = { TRANSCODING: { get ENABLED () { return config.get('transcoding.enabled') }, get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get('transcoding.allow_additional_extensions') }, + get ALLOW_AUDIO_FILES () { return config.get('transcoding.allow_audio_files') }, get THREADS () { return config.get('transcoding.threads') }, RESOLUTIONS: { get '240p' () { return config.get('transcoding.resolutions.240p') }, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 62778ae58..718d0893b 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -1,10 +1,10 @@ import { join } from 'path' -import { JobType, VideoRateType, VideoState } from '../../shared/models' +import { JobType, VideoRateType, VideoResolution, VideoState } from '../../shared/models' import { ActivityPubActorType } from '../../shared/models/activitypub' import { FollowState } from '../../shared/models/actors' import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos' // Do not use barrels, remain constants as independent as possible -import { isTestInstance, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' +import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils' import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' import { invert } from 'lodash' import { CronRepeatOptions, EveryRepeatOptions } from 'bull' @@ -228,7 +228,7 @@ let CONSTRAINTS_FIELDS = { max: 2 * 1024 * 1024 // 2MB } }, - EXTNAME: buildVideosExtname(), + EXTNAME: [] as string[], INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2 DURATION: { min: 0 }, // Number TAGS: { min: 0, max: 5 }, // Number of total tags @@ -300,6 +300,8 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) } +const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P + const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = { LIKE: 'like', DISLIKE: 'dislike' @@ -380,8 +382,18 @@ const VIDEO_PLAYLIST_TYPES = { } const MIMETYPES = { + AUDIO: { + MIMETYPE_EXT: { + 'audio/mpeg': '.mp3', + 'audio/mp3': '.mp3', + 'application/ogg': '.ogg', + 'audio/ogg': '.ogg', + 'audio/flac': '.flac' + }, + EXT_MIMETYPE: null as { [ id: string ]: string } + }, VIDEO: { - MIMETYPE_EXT: buildVideoMimetypeExt(), + MIMETYPE_EXT: null as { [ id: string ]: string }, EXT_MIMETYPE: null as { [ id: string ]: string } }, IMAGE: { @@ -403,7 +415,7 @@ const MIMETYPES = { } } } -MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT) +MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT) // --------------------------------------------------------------------------- @@ -429,7 +441,7 @@ const ACTIVITY_PUB = { COLLECTION_ITEMS_PER_PAGE: 10, FETCH_PAGE_LIMIT: 100, URL_MIME_TYPES: { - VIDEO: Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT), + VIDEO: [] as string[], TORRENT: [ 'application/x-bittorrent' ], MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ] }, @@ -543,6 +555,10 @@ const REDUNDANCY = { const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) +const ASSETS_PATH = { + DEFAULT_AUDIO_BACKGROUND: join(root(), 'server', 'assets', 'default-audio-background.jpg') +} + // --------------------------------------------------------------------------- const CUSTOM_HTML_TAG_COMMENTS = { @@ -612,6 +628,7 @@ if (isTestInstance() === true) { } updateWebserverUrls() +updateWebserverConfig() registerConfigChangedHandler(() => { updateWebserverUrls() @@ -681,12 +698,14 @@ export { RATES_LIMIT, MIMETYPES, CRAWL_REQUEST_CONCURRENCY, + DEFAULT_AUDIO_RESOLUTION, JOB_COMPLETED_LIFETIME, HTTP_SIGNATURE, VIDEO_IMPORT_STATES, VIDEO_VIEW_LIFETIME, CONTACT_FORM_LIFETIME, VIDEO_PLAYLIST_PRIVACIES, + ASSETS_PATH, loadLanguages, buildLanguages } @@ -700,15 +719,21 @@ function buildVideoMimetypeExt () { 'video/mp4': '.mp4' } - if (CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) { - Object.assign(data, { - 'video/quicktime': '.mov', - 'video/x-msvideo': '.avi', - 'video/x-flv': '.flv', - 'video/x-matroska': '.mkv', - 'application/octet-stream': '.mkv', - 'video/avi': '.avi' - }) + if (CONFIG.TRANSCODING.ENABLED) { + if (CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) { + Object.assign(data, { + 'video/quicktime': '.mov', + 'video/x-msvideo': '.avi', + 'video/x-flv': '.flv', + 'video/x-matroska': '.mkv', + 'application/octet-stream': '.mkv', + 'video/avi': '.avi' + }) + } + + if (CONFIG.TRANSCODING.ALLOW_AUDIO_FILES) { + Object.assign(data, MIMETYPES.AUDIO.MIMETYPE_EXT) + } } return data @@ -724,16 +749,15 @@ function updateWebserverUrls () { } function updateWebserverConfig () { - CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname() - MIMETYPES.VIDEO.MIMETYPE_EXT = buildVideoMimetypeExt() MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT) + ACTIVITY_PUB.URL_MIME_TYPES.VIDEO = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT) + + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname() } function buildVideosExtname () { - return CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS - ? [ '.mp4', '.ogv', '.webm', '.mkv', '.mov', '.avi', '.flv' ] - : [ '.mp4', '.ogv', '.webm' ] + return Object.keys(MIMETYPES.VIDEO.EXT_MIMETYPE) } function loadLanguages () { diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts index 14be7f24a..a68619d07 100644 --- a/server/lib/files-cache/videos-preview-cache.ts +++ b/server/lib/files-cache/videos-preview-cache.ts @@ -21,7 +21,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache { const video = await VideoModel.loadByUUIDWithFile(videoUUID) if (!video) return undefined - if (video.isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreview().filename) } + if (video.isOwned()) return { isOwned: true, path: video.getPreview().getPath() } return this.loadRemoteFile(videoUUID) } diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts index 921d9a083..8cacb0ef3 100644 --- a/server/lib/job-queue/handlers/video-file-import.ts +++ b/server/lib/job-queue/handlers/video-file-import.ts @@ -1,7 +1,7 @@ import * as Bull from 'bull' import { logger } from '../../../helpers/logger' import { VideoModel } from '../../../models/video/video' -import { publishVideoIfNeeded } from './video-transcoding' +import { publishNewResolutionIfNeeded } from './video-transcoding' import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' import { copy, stat } from 'fs-extra' import { VideoFileModel } from '../../../models/video/video-file' @@ -25,7 +25,7 @@ async function processVideoFileImport (job: Bull.Job) { await updateVideoFile(video, payload.filePath) - await publishVideoIfNeeded(video) + await publishNewResolutionIfNeeded(video) return video } diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 1650916a6..50e159245 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -209,6 +209,7 @@ async function processFile (downloader: () => Promise, videoImport: Vide if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) { // Put uuid because we don't have id auto incremented for now const dataInput = { + type: 'optimize' as 'optimize', videoUUID: videoImportUpdated.Video.uuid, isNewVideo: true } diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 48cac517e..e9b84ecd6 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -8,18 +8,39 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils' import { sequelizeTypescript } from '../../../initializers' import * as Bluebird from 'bluebird' import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' -import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding' +import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile, mergeAudioVideofile } from '../../video-transcoding' import { Notifier } from '../../notifier' import { CONFIG } from '../../../initializers/config' -export type VideoTranscodingPayload = { +interface BaseTranscodingPayload { videoUUID: string - resolution?: VideoResolution isNewVideo?: boolean +} + +interface HLSTranscodingPayload extends BaseTranscodingPayload { + type: 'hls' + isPortraitMode?: boolean + resolution: VideoResolution +} + +interface NewResolutionTranscodingPayload extends BaseTranscodingPayload { + type: 'new-resolution' isPortraitMode?: boolean - generateHlsPlaylist?: boolean + resolution: VideoResolution +} + +interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { + type: 'merge-audio' + resolution: VideoResolution +} + +interface OptimizeTranscodingPayload extends BaseTranscodingPayload { + type: 'optimize' } +export type VideoTranscodingPayload = HLSTranscodingPayload | NewResolutionTranscodingPayload + | OptimizeTranscodingPayload | MergeAudioTranscodingPayload + async function processVideoTranscoding (job: Bull.Job) { const payload = job.data as VideoTranscodingPayload logger.info('Processing video file in job %d.', job.id) @@ -31,14 +52,18 @@ async function processVideoTranscoding (job: Bull.Job) { return undefined } - if (payload.generateHlsPlaylist) { + if (payload.type === 'hls') { await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false) await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video) - } else if (payload.resolution) { // Transcoding in other resolution + } else if (payload.type === 'new-resolution') { await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false) - await retryTransactionWrapper(publishVideoIfNeeded, video, payload) + await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload) + } else if (payload.type === 'merge-audio') { + await mergeAudioVideofile(video, payload.resolution) + + await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload) } else { await optimizeVideofile(video) @@ -62,7 +87,7 @@ async function onHlsPlaylistGenerationSuccess (video: VideoModel) { }) } -async function publishVideoIfNeeded (video: VideoModel, payload?: VideoTranscodingPayload) { +async function publishNewResolutionIfNeeded (video: VideoModel, payload?: NewResolutionTranscodingPayload | MergeAudioTranscodingPayload) { const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { // Maybe the video changed in database, refresh it let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) @@ -94,7 +119,7 @@ async function publishVideoIfNeeded (video: VideoModel, payload?: VideoTranscodi await createHlsJobIfEnabled(payload) } -async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: VideoTranscodingPayload) { +async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: OptimizeTranscodingPayload) { if (videoArg === undefined) return undefined // Outside the transaction (IO on disk) @@ -120,6 +145,7 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: Video for (const resolution of resolutionsEnabled) { const dataInput = { + type: 'new-resolution' as 'new-resolution', videoUUID: videoDatabase.uuid, resolution } @@ -149,27 +175,27 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: Video if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase) if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase) - await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution })) + const hlsPayload = Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }) + await createHlsJobIfEnabled(hlsPayload) } // --------------------------------------------------------------------------- export { processVideoTranscoding, - publishVideoIfNeeded + publishNewResolutionIfNeeded } // --------------------------------------------------------------------------- -function createHlsJobIfEnabled (payload?: VideoTranscodingPayload) { +function createHlsJobIfEnabled (payload?: { videoUUID: string, resolution: number, isPortraitMode?: boolean }) { // Generate HLS playlist? if (payload && CONFIG.TRANSCODING.HLS.ENABLED) { const hlsTranscodingPayload = { + type: 'hls' as 'hls', videoUUID: payload.videoUUID, resolution: payload.resolution, - isPortraitMode: payload.isPortraitMode, - - generateHlsPlaylist: true + isPortraitMode: payload.isPortraitMode } return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload }) diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts index 950b14c3b..18bdcded4 100644 --- a/server/lib/thumbnail.ts +++ b/server/lib/thumbnail.ts @@ -1,7 +1,7 @@ import { VideoFileModel } from '../models/video/video-file' import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils' import { CONFIG } from '../initializers/config' -import { PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' +import { PREVIEWS_SIZE, THUMBNAILS_SIZE, ASSETS_PATH } from '../initializers/constants' import { VideoModel } from '../models/video/video' import { ThumbnailModel } from '../models/video/thumbnail' import { ThumbnailType } from '../../shared/models/videos/thumbnail.type' @@ -45,8 +45,10 @@ function createVideoMiniatureFromExisting (inputPath: string, video: VideoModel, function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) { const input = video.getVideoFilePath(videoFile) - const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type) - const thumbnailCreator = () => generateImageFromVideoFile(input, basePath, filename, { height, width }) + const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type) + const thumbnailCreator = videoFile.isAudio() + ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true) + : () => generateImageFromVideoFile(input, basePath, filename, { height, width }) return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail }) } diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index d6b6b251a..8d786e0ef 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts @@ -1,6 +1,6 @@ import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants' import { join } from 'path' -import { getVideoFileFPS, transcode, canDoQuickTranscode } from '../helpers/ffmpeg-utils' +import { canDoQuickTranscode, getVideoFileFPS, transcode, TranscodeOptions, TranscodeOptionsType } from '../helpers/ffmpeg-utils' import { ensureDir, move, remove, stat } from 'fs-extra' import { logger } from '../helpers/logger' import { VideoResolution } from '../../shared/models/videos' @@ -23,13 +23,15 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile)) const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) - const doQuickTranscode = await(canDoQuickTranscode(videoInputPath)) + const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath) + ? 'quick-transcode' + : 'video' - const transcodeOptions = { + const transcodeOptions: TranscodeOptions = { + type: transcodeType as any, // FIXME: typing issue inputPath: videoInputPath, outputPath: videoTranscodedPath, - resolution: inputVideoFile.resolution, - doQuickTranscode + resolution: inputVideoFile.resolution } // Could be very long! @@ -39,19 +41,11 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi await remove(videoInputPath) // Important to do this before getVideoFilename() to take in account the new file extension - inputVideoFile.set('extname', newExtname) - - const stats = await stat(videoTranscodedPath) - const fps = await getVideoFileFPS(videoTranscodedPath) + inputVideoFile.extname = newExtname const videoOutputPath = video.getVideoFilePath(inputVideoFile) - await move(videoTranscodedPath, videoOutputPath) - inputVideoFile.set('size', stats.size) - inputVideoFile.set('fps', fps) - - await video.createTorrentAndSetInfoHash(inputVideoFile) - await inputVideoFile.save() + await onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) } catch (err) { // Auto destruction... video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err })) @@ -81,6 +75,7 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile)) const transcodeOptions = { + type: 'video' as 'video', inputPath: videoInputPath, outputPath: videoTranscodedPath, resolution, @@ -89,19 +84,37 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR await transcode(transcodeOptions) - const stats = await stat(videoTranscodedPath) - const fps = await getVideoFileFPS(videoTranscodedPath) + return onVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) +} + +async function mergeAudioVideofile (video: VideoModel, resolution: VideoResolution) { + const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR + const transcodeDirectory = CONFIG.STORAGE.TMP_DIR + const newExtname = '.mp4' + + const inputVideoFile = video.getOriginalFile() - await move(videoTranscodedPath, videoOutputPath) + const audioInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile())) + const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) - newVideoFile.set('size', stats.size) - newVideoFile.set('fps', fps) + const transcodeOptions = { + type: 'merge-audio' as 'merge-audio', + inputPath: video.getPreview().getPath(), + outputPath: videoTranscodedPath, + audioPath: audioInputPath, + resolution + } - await video.createTorrentAndSetInfoHash(newVideoFile) + await transcode(transcodeOptions) - await newVideoFile.save() + await remove(audioInputPath) - video.VideoFiles.push(newVideoFile) + // Important to do this before getVideoFilename() to take in account the new file extension + inputVideoFile.extname = newExtname + + const videoOutputPath = video.getVideoFilePath(inputVideoFile) + + return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) } async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) { @@ -112,6 +125,7 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) const transcodeOptions = { + type: 'hls' as 'hls', inputPath: videoInputPath, outputPath, resolution, @@ -140,8 +154,34 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti }) } +// --------------------------------------------------------------------------- + export { generateHlsPlaylist, optimizeVideofile, - transcodeOriginalVideofile + transcodeOriginalVideofile, + mergeAudioVideofile +} + +// --------------------------------------------------------------------------- + +async function onVideoFileTranscoding (video: VideoModel, videoFile: VideoFileModel, transcodingPath: string, outputPath: string) { + const stats = await stat(transcodingPath) + const fps = await getVideoFileFPS(transcodingPath) + + await move(transcodingPath, outputPath) + + videoFile.set('size', stats.size) + videoFile.set('fps', fps) + + await video.createTorrentAndSetInfoHash(videoFile) + + const updatedVideoFile = await videoFile.save() + + // Add it if this is a new created file + if (video.VideoFiles.some(f => f.id === videoFile.id) === false) { + video.VideoFiles.push(updatedVideoFile) + } + + return video } diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts index 206e9a3d6..8faf0adba 100644 --- a/server/models/video/thumbnail.ts +++ b/server/models/video/thumbnail.ts @@ -107,10 +107,12 @@ export class ThumbnailModel extends Model { return WEBSERVER.URL + staticPath + this.filename } - removeThumbnail () { + getPath () { const directory = ThumbnailModel.types[this.type].directory - const thumbnailPath = join(directory, this.filename) + return join(directory, this.filename) + } - return remove(thumbnailPath) + removeThumbnail () { + return remove(this.getPath()) } } diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index 2203a7aba..05c490759 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts @@ -24,6 +24,7 @@ import { VideoModel } from './video' import { VideoRedundancyModel } from '../redundancy/video-redundancy' import { VideoStreamingPlaylistModel } from './video-streaming-playlist' import { FindOptions, QueryTypes, Transaction } from 'sequelize' +import { MIMETYPES } from '../../initializers/constants' @Table({ tableName: 'videoFile', @@ -161,6 +162,10 @@ export class VideoFileModel extends Model { })) } + isAudio () { + return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname] + } + hasSameUniqueKeysThan (other: VideoFileModel) { return this.fps === other.fps && this.resolution === other.resolution && diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index 2a2ec606a..8155e11ab 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts @@ -59,6 +59,7 @@ describe('Test config API validators', function () { transcoding: { enabled: true, allowAdditionalExtensions: true, + allowAudioFiles: true, threads: 1, resolutions: { '240p': false, diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index ca389b7b6..2ad477c99 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -52,6 +52,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) { expect(data.user.videoQuotaDaily).to.equal(-1) expect(data.transcoding.enabled).to.be.false expect(data.transcoding.allowAdditionalExtensions).to.be.false + expect(data.transcoding.allowAudioFiles).to.be.false expect(data.transcoding.threads).to.equal(2) expect(data.transcoding.resolutions['240p']).to.be.true expect(data.transcoding.resolutions['360p']).to.be.true @@ -102,6 +103,7 @@ function checkUpdatedConfig (data: CustomConfig) { expect(data.transcoding.enabled).to.be.true expect(data.transcoding.threads).to.equal(1) expect(data.transcoding.allowAdditionalExtensions).to.be.true + expect(data.transcoding.allowAudioFiles).to.be.true expect(data.transcoding.resolutions['240p']).to.be.false expect(data.transcoding.resolutions['360p']).to.be.true expect(data.transcoding.resolutions['480p']).to.be.true @@ -215,6 +217,7 @@ describe('Test config', function () { transcoding: { enabled: true, allowAdditionalExtensions: true, + allowAudioFiles: true, threads: 1, resolutions: { '240p': false, diff --git a/shared/extra-utils/server/config.ts b/shared/extra-utils/server/config.ts index deb77e9c0..a5f5989e0 100644 --- a/shared/extra-utils/server/config.ts +++ b/shared/extra-utils/server/config.ts @@ -91,6 +91,7 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) { transcoding: { enabled: true, allowAdditionalExtensions: true, + allowAudioFiles: true, threads: 1, resolutions: { '240p': false, diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index ca52eff4b..4cc379b2a 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -54,6 +54,7 @@ export interface CustomConfig { transcoding: { enabled: boolean allowAdditionalExtensions: boolean + allowAudioFiles: boolean threads: number resolutions: { '240p': boolean -- cgit v1.2.3 From 7b992a86b107fc2917b993127df8e95a66ae94db Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 17 May 2019 10:45:53 +0200 Subject: Support audio upload in client --- .../my-account-video-playlist-edit.component.html | 10 +- .../my-account-video-playlist-edit.ts | 2 - .../src/app/shared/buttons/button.component.scss | 12 --- client/src/app/shared/buttons/button.component.ts | 2 +- .../app/shared/forms/reactive-file.component.html | 7 +- .../app/shared/forms/reactive-file.component.scss | 10 +- .../app/shared/forms/reactive-file.component.ts | 2 + .../app/shared/images/image-upload.component.html | 9 -- .../app/shared/images/image-upload.component.scss | 18 ---- .../app/shared/images/image-upload.component.ts | 69 -------------- .../shared/images/preview-upload.component.html | 13 +++ .../shared/images/preview-upload.component.scss | 27 ++++++ .../app/shared/images/preview-upload.component.ts | 74 ++++++++++++++ client/src/app/shared/shared.module.ts | 6 +- client/src/app/shared/video/video-edit.model.ts | 5 + .../+video-edit/shared/video-edit.component.html | 14 +-- .../+video-edit/shared/video-edit.component.ts | 1 - .../video-upload.component.html | 21 ++++ .../video-upload.component.scss | 17 +++- .../video-add-components/video-upload.component.ts | 106 +++++++++++++++------ 20 files changed, 255 insertions(+), 170 deletions(-) delete mode 100644 client/src/app/shared/images/image-upload.component.html delete mode 100644 client/src/app/shared/images/image-upload.component.scss delete mode 100644 client/src/app/shared/images/image-upload.component.ts create mode 100644 client/src/app/shared/images/preview-upload.component.html create mode 100644 client/src/app/shared/images/preview-upload.component.scss create mode 100644 client/src/app/shared/images/preview-upload.component.ts diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html index 303fc46f7..82321459f 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html @@ -57,10 +57,12 @@
- + + +
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts index fbfb4c8f7..81dd9a75f 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts @@ -1,6 +1,4 @@ import { FormReactive } from '@app/shared' -import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { ServerService } from '@app/core' import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model' export abstract class MyAccountVideoPlaylistEdit extends FormReactive { diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss index 04199a2a9..7ec77f4c9 100644 --- a/client/src/app/shared/buttons/button.component.scss +++ b/client/src/app/shared/buttons/button.component.scss @@ -4,18 +4,6 @@ .action-button { @include peertube-button-link; @include button-with-icon(21px, 0, -2px); - - font-weight: $font-semibold; - color: $grey-foreground-color; - background-color: $grey-background-color; - - &:hover { - background-color: $grey-background-hover-color; - } - - my-global-icon { - @include apply-svg-color($grey-foreground-color); - } } // In a table, try to minimize the space taken by this button diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts index c2b69d31a..6d34d07f4 100644 --- a/client/src/app/shared/buttons/button.component.ts +++ b/client/src/app/shared/buttons/button.component.ts @@ -9,7 +9,7 @@ import { GlobalIconName } from '@app/shared/images/global-icon.component' export class ButtonComponent { @Input() label = '' - @Input() className: string = undefined + @Input() className: 'orange-button' | 'grey-button' = 'grey-button' @Input() icon: GlobalIconName = undefined @Input() title: string = undefined diff --git a/client/src/app/shared/forms/reactive-file.component.html b/client/src/app/shared/forms/reactive-file.component.html index 7d691059d..f6bf5f9ae 100644 --- a/client/src/app/shared/forms/reactive-file.component.html +++ b/client/src/app/shared/forms/reactive-file.component.html @@ -1,6 +1,9 @@
-
+
+ + {{ inputLabel }} +
-
(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})
-
{{ filename }}
diff --git a/client/src/app/shared/forms/reactive-file.component.scss b/client/src/app/shared/forms/reactive-file.component.scss index d89844264..84c23c1d6 100644 --- a/client/src/app/shared/forms/reactive-file.component.scss +++ b/client/src/app/shared/forms/reactive-file.component.scss @@ -8,13 +8,11 @@ .button-file { @include peertube-button-file(auto); + @include grey-button; - min-width: 190px; - } - - .file-constraints { - margin-left: 5px; - font-size: 13px; + &.with-icon { + @include button-with-icon; + } } .filename { diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts index f60c38e8d..b7a821d4f 100644 --- a/client/src/app/shared/forms/reactive-file.component.ts +++ b/client/src/app/shared/forms/reactive-file.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@ang import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { Notifier } from '@app/core' import { I18n } from '@ngx-translate/i18n-polyfill' +import { GlobalIconName } from '@app/shared/images/global-icon.component' @Component({ selector: 'my-reactive-file', @@ -21,6 +22,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor { @Input() extensions: string[] = [] @Input() maxFileSize: number @Input() displayFilename = false + @Input() icon: GlobalIconName @Output() fileChanged = new EventEmitter() diff --git a/client/src/app/shared/images/image-upload.component.html b/client/src/app/shared/images/image-upload.component.html deleted file mode 100644 index c09c862c4..000000000 --- a/client/src/app/shared/images/image-upload.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
- - - -
-
diff --git a/client/src/app/shared/images/image-upload.component.scss b/client/src/app/shared/images/image-upload.component.scss deleted file mode 100644 index b63963bca..000000000 --- a/client/src/app/shared/images/image-upload.component.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.root { - height: auto; - display: flex; - align-items: center; - - .preview { - border: 2px solid grey; - border-radius: 4px; - margin-left: 50px; - - &.no-image { - background-color: #ececec; - } - } -} diff --git a/client/src/app/shared/images/image-upload.component.ts b/client/src/app/shared/images/image-upload.component.ts deleted file mode 100644 index 2da1592ff..000000000 --- a/client/src/app/shared/images/image-upload.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Component, forwardRef, Input } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' -import { ServerService } from '@app/core' - -@Component({ - selector: 'my-image-upload', - styleUrls: [ './image-upload.component.scss' ], - templateUrl: './image-upload.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageUploadComponent), - multi: true - } - ] -}) -export class ImageUploadComponent implements ControlValueAccessor { - @Input() inputLabel: string - @Input() inputName: string - @Input() previewWidth: string - @Input() previewHeight: string - - imageSrc: SafeResourceUrl - - private file: File - - constructor ( - private sanitizer: DomSanitizer, - private serverService: ServerService - ) {} - - get videoImageExtensions () { - return this.serverService.getConfig().video.image.extensions - } - - get maxVideoImageSize () { - return this.serverService.getConfig().video.image.size.max - } - - onFileChanged (file: File) { - this.file = file - - this.propagateChange(this.file) - this.updatePreview() - } - - propagateChange = (_: any) => { /* empty */ } - - writeValue (file: any) { - this.file = file - this.updatePreview() - } - - registerOnChange (fn: (_: any) => void) { - this.propagateChange = fn - } - - registerOnTouched () { - // Unused - } - - private updatePreview () { - if (this.file) { - const url = URL.createObjectURL(this.file) - this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url) - } - } -} diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html new file mode 100644 index 000000000..5e1d5211b --- /dev/null +++ b/client/src/app/shared/images/preview-upload.component.html @@ -0,0 +1,13 @@ +
+
+ + + +
+
+ +
(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})
+
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss new file mode 100644 index 000000000..257060239 --- /dev/null +++ b/client/src/app/shared/images/preview-upload.component.scss @@ -0,0 +1,27 @@ +@import '_variables'; +@import '_mixins'; + +.root { + height: auto; + display: flex; + flex-direction: column; + + .preview-container { + position: relative; + + my-reactive-file { + position: absolute; + bottom: 10px; + left: 10px; + } + + .preview { + border: 2px solid grey; + border-radius: 4px; + + &.no-image { + background-color: #ececec; + } + } + } +} diff --git a/client/src/app/shared/images/preview-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts new file mode 100644 index 000000000..44b78866e --- /dev/null +++ b/client/src/app/shared/images/preview-upload.component.ts @@ -0,0 +1,74 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' +import { ServerService } from '@app/core' + +@Component({ + selector: 'my-preview-upload', + styleUrls: [ './preview-upload.component.scss' ], + templateUrl: './preview-upload.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PreviewUploadComponent), + multi: true + } + ] +}) +export class PreviewUploadComponent implements OnInit, ControlValueAccessor { + @Input() inputLabel: string + @Input() inputName: string + @Input() previewWidth: string + @Input() previewHeight: string + + imageSrc: SafeResourceUrl + allowedExtensionsMessage = '' + + private file: File + + constructor ( + private sanitizer: DomSanitizer, + private serverService: ServerService + ) {} + + get videoImageExtensions () { + return this.serverService.getConfig().video.image.extensions + } + + get maxVideoImageSize () { + return this.serverService.getConfig().video.image.size.max + } + + ngOnInit () { + this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') + } + + onFileChanged (file: File) { + this.file = file + + this.propagateChange(this.file) + this.updatePreview() + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (file: any) { + this.file = file + this.updatePreview() + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + private updatePreview () { + if (this.file) { + const url = URL.createObjectURL(this.file) + this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url) + } + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index ded65653f..39f1a69e2 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -69,7 +69,7 @@ import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/sha import { ConfirmComponent } from '@app/shared/confirm/confirm.component' import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' -import { ImageUploadComponent } from '@app/shared/images/image-upload.component' +import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component' import { GlobalIconComponent } from '@app/shared/images/global-icon.component' import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' @@ -154,7 +154,7 @@ import { ClipboardModule } from 'ngx-clipboard' ConfirmComponent, GlobalIconComponent, - ImageUploadComponent + PreviewUploadComponent ], exports: [ @@ -218,7 +218,7 @@ import { ClipboardModule } from 'ngx-clipboard' ConfirmComponent, GlobalIconComponent, - ImageUploadComponent, + PreviewUploadComponent, NumberFormatterPipe, ObjectLengthPipe, diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts index 1f633d427..67d8e7711 100644 --- a/client/src/app/shared/video/video-edit.model.ts +++ b/client/src/app/shared/video/video-edit.model.ts @@ -85,6 +85,11 @@ export class VideoEdit implements VideoUpdate { const originallyPublishedAt = new Date(values['originallyPublishedAt']) this.originallyPublishedAt = originallyPublishedAt.toISOString() } + + // Use the same file than the preview for the thumbnail + if (this.previewfile) { + this.thumbnailfile = this.previewfile + } } toFormPatch () { diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html index 99695204d..28572d611 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html @@ -187,18 +187,14 @@
-
- -
- Video preview + + + >
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts index c80efd802..95d397b52 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts @@ -100,7 +100,6 @@ export class VideoEditComponent implements OnInit, OnDestroy { language: this.videoValidatorsService.VIDEO_LANGUAGE, description: this.videoValidatorsService.VIDEO_DESCRIPTION, tags: null, - thumbnailfile: null, previewfile: null, support: this.videoValidatorsService.VIDEO_SUPPORT, schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT, diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html index 536769d2f..3247a2bd6 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html +++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html @@ -26,6 +26,27 @@
+ + +
+ + +
+ Image that will be merged with your audio file. +
+ The chosen image will be definitive and cannot be modified. +
+ + +
+ +
+ +
+
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss index 8adf8f169..684342f09 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss +++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss @@ -1,9 +1,20 @@ @import 'variables'; @import 'mixins'; -.first-step-block .form-group-channel { - margin-bottom: 20px; - margin-top: 35px; +.first-step-block { + + .form-group-channel { + margin-bottom: 20px; + margin-top: 35px; + } + + .audio-image-info { + margin-bottom: 10px; + } + + .audio-preview { + margin: 30px 0; + } } .upload-progress-cancel { diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts index d6d4bad21..73de25c59 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts @@ -35,8 +35,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy userVideoQuotaUsed = 0 userVideoQuotaUsedDaily = 0 + isUploadingAudioFile = false isUploadingVideo = false isUpdatingVideo = false + videoUploaded = false videoUploadObservable: Subscription = null videoUploadPercents = 0 @@ -44,7 +46,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy id: 0, uuid: '' } + waitTranscodingEnabled = true + previewfileUpload: File error: string @@ -100,6 +104,17 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy } } + getVideoFile () { + return this.videofileInput.nativeElement.files[0] + } + + getAudioUploadLabel () { + const videofile = this.getVideoFile() + if (!videofile) return this.i18n('Upload') + + return this.i18n('Upload {{videofileName}}', { videofileName: videofile.name }) + } + fileChange () { this.uploadFirstStep() } @@ -114,38 +129,15 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy } } - uploadFirstStep () { - const videofile = this.videofileInput.nativeElement.files[0] + uploadFirstStep (clickedOnButton = false) { + const videofile = this.getVideoFile() if (!videofile) return - // Check global user quota - const bytePipes = new BytesPipe() - const videoQuota = this.authService.getUser().videoQuota - if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { - const msg = this.i18n( - 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})', - { - videoSize: bytePipes.transform(videofile.size, 0), - videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0), - videoQuota: bytePipes.transform(videoQuota, 0) - } - ) - this.notifier.error(msg) - return - } + if (!this.checkGlobalUserQuota(videofile)) return + if (!this.checkDailyUserQuota(videofile)) return - // Check daily user quota - const videoQuotaDaily = this.authService.getUser().videoQuotaDaily - if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { - const msg = this.i18n( - 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})', - { - videoSize: bytePipes.transform(videofile.size, 0), - quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0), - quotaDaily: bytePipes.transform(videoQuotaDaily, 0) - } - ) - this.notifier.error(msg) + if (clickedOnButton === false && this.isAudioFile(videofile.name)) { + this.isUploadingAudioFile = true return } @@ -180,6 +172,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy formData.append('channelId', '' + channelId) formData.append('videofile', videofile) + if (this.previewfileUpload) { + formData.append('previewfile', this.previewfileUpload) + formData.append('thumbnailfile', this.previewfileUpload) + } + this.isUploadingVideo = true this.firstStepDone.emit(name) @@ -187,7 +184,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy name, privacy, nsfw, - channelId + channelId, + previewfile: this.previewfileUpload }) this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) @@ -251,4 +249,52 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy } ) } + + private checkGlobalUserQuota (videofile: File) { + const bytePipes = new BytesPipe() + + // Check global user quota + const videoQuota = this.authService.getUser().videoQuota + if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { + const msg = this.i18n( + 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})', + { + videoSize: bytePipes.transform(videofile.size, 0), + videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0), + videoQuota: bytePipes.transform(videoQuota, 0) + } + ) + this.notifier.error(msg) + + return false + } + + return true + } + + private checkDailyUserQuota (videofile: File) { + const bytePipes = new BytesPipe() + + // Check daily user quota + const videoQuotaDaily = this.authService.getUser().videoQuotaDaily + if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { + const msg = this.i18n( + 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})', + { + videoSize: bytePipes.transform(videofile.size, 0), + quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0), + quotaDaily: bytePipes.transform(videoQuotaDaily, 0) + } + ) + this.notifier.error(msg) + + return false + } + + return true + } + + private isAudioFile (filename: string) { + return filename.endsWith('.mp3') || filename.endsWith('.flac') || filename.endsWith('.ogg') + } } -- cgit v1.2.3 From b345a8047bfde1efa1fdbd249f25f663151265a8 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 17 May 2019 11:56:12 +0200 Subject: Add audio upload tests --- .../my-account-videos.component.html | 2 +- client/src/app/shared/buttons/button.component.ts | 2 +- .../shared/buttons/delete-button.component.html | 2 +- .../app/shared/buttons/edit-button.component.html | 2 +- config/test-2.yaml | 1 + server/controllers/api/videos/index.ts | 11 ++++- server/initializers/constants.ts | 4 +- server/tests/api/server/config.ts | 11 ++++- server/tests/api/server/jobs.ts | 2 +- server/tests/api/travis-2.sh | 2 +- server/tests/api/videos/video-hls.ts | 41 ++++++++++++----- server/tests/api/videos/video-transcoder.ts | 51 +++++++++++++++++++++ server/tests/fixtures/sample.ogg | Bin 0 -> 105243 bytes 13 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 server/tests/fixtures/sample.ogg diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html index d7993fdc2..38b48f1d6 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html @@ -19,7 +19,7 @@ diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts index 6d34d07f4..cf334e8d5 100644 --- a/client/src/app/shared/buttons/button.component.ts +++ b/client/src/app/shared/buttons/button.component.ts @@ -9,7 +9,7 @@ import { GlobalIconName } from '@app/shared/images/global-icon.component' export class ButtonComponent { @Input() label = '' - @Input() className: 'orange-button' | 'grey-button' = 'grey-button' + @Input() className = 'grey-button' @Input() icon: GlobalIconName = undefined @Input() title: string = undefined diff --git a/client/src/app/shared/buttons/delete-button.component.html b/client/src/app/shared/buttons/delete-button.component.html index 4d12a84c0..d278e7015 100644 --- a/client/src/app/shared/buttons/delete-button.component.html +++ b/client/src/app/shared/buttons/delete-button.component.html @@ -1,4 +1,4 @@ - + {{ label }} diff --git a/client/src/app/shared/buttons/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html index da3addbae..3d7cd4780 100644 --- a/client/src/app/shared/buttons/edit-button.component.html +++ b/client/src/app/shared/buttons/edit-button.component.html @@ -1,4 +1,4 @@ - + {{ label }} diff --git a/config/test-2.yaml b/config/test-2.yaml index a5515afa4..de7300366 100644 --- a/config/test-2.yaml +++ b/config/test-2.yaml @@ -31,3 +31,4 @@ signup: transcoding: enabled: true allow_additional_extensions: true + allow_audio_files: true diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index a2a615a79..40a2c972b 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -1,12 +1,19 @@ import * as express from 'express' import { extname, join } from 'path' -import { VideoCreate, VideoPrivacy, VideoResolution, VideoState, VideoUpdate } from '../../../../shared' +import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' import { logger } from '../../../helpers/logger' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { getFormattedObjects, getServerActor } from '../../../helpers/utils' import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' -import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants' +import { + DEFAULT_AUDIO_RESOLUTION, + MIMETYPES, + VIDEO_CATEGORIES, + VIDEO_LANGUAGES, + VIDEO_LICENCES, + VIDEO_PRIVACIES +} from '../../../initializers/constants' import { changeVideoChannelShare, federateVideoIfNeeded, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 718d0893b..8a11101ff 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -509,8 +509,8 @@ const THUMBNAILS_SIZE = { height: 122 } const PREVIEWS_SIZE = { - width: 560, - height: 315 + width: 850, + height: 480 } const AVATARS_SIZE = { width: 120, diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index 2ad477c99..8ea21158a 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -15,7 +15,7 @@ import { registerUser, reRunServer, ServerInfo, setAccessTokensToServers, - updateCustomConfig + updateCustomConfig, uploadVideo } from '../../../../shared/extra-utils' import { ServerConfig } from '../../../../shared/models' @@ -160,6 +160,9 @@ describe('Test config', function () { expect(data.video.file.extensions).to.contain('.webm') expect(data.video.file.extensions).to.contain('.ogv') + await uploadVideo(server.url, server.accessToken, { fixture: 'video_short.mkv' }, 400) + await uploadVideo(server.url, server.accessToken, { fixture: 'sample.ogg' }, 400) + expect(data.contactForm.enabled).to.be.true }) @@ -272,6 +275,12 @@ describe('Test config', function () { expect(data.video.file.extensions).to.contain('.ogv') expect(data.video.file.extensions).to.contain('.flv') expect(data.video.file.extensions).to.contain('.mkv') + expect(data.video.file.extensions).to.contain('.mp3') + expect(data.video.file.extensions).to.contain('.ogg') + expect(data.video.file.extensions).to.contain('.flac') + + await uploadVideo(server.url, server.accessToken, { fixture: 'video_short.mkv' }, 200) + await uploadVideo(server.url, server.accessToken, { fixture: 'sample.ogg' }, 200) }) it('Should have the configuration updated after a restart', async function () { diff --git a/server/tests/api/server/jobs.ts b/server/tests/api/server/jobs.ts index 634654626..3ab2fe120 100644 --- a/server/tests/api/server/jobs.ts +++ b/server/tests/api/server/jobs.ts @@ -26,7 +26,7 @@ describe('Test jobs', function () { }) it('Should create some jobs', async function () { - this.timeout(30000) + this.timeout(60000) await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video1' }) await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video2' }) diff --git a/server/tests/api/travis-2.sh b/server/tests/api/travis-2.sh index 82c1864b4..ba7a061b0 100644 --- a/server/tests/api/travis-2.sh +++ b/server/tests/api/travis-2.sh @@ -5,5 +5,5 @@ set -eu serverFiles=$(find server/tests/api/server -type f | grep -v index.ts | xargs echo) usersFiles=$(find server/tests/api/users -type f | grep -v index.ts | xargs echo) -MOCHA_PARALLEL=true mocha-parallel-tests --max-parallel $1 --timeout 5000 --exit --require ts-node/register --bail \ +MOCHA_PARALLEL=true mocha --timeout 5000 --exit --require ts-node/register --bail \ $serverFiles $usersFiles diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts index 504c50dee..eacd9ab79 100644 --- a/server/tests/api/videos/video-hls.ts +++ b/server/tests/api/videos/video-hls.ts @@ -21,12 +21,11 @@ import { import { VideoDetails } from '../../../../shared/models/videos' import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type' import { join } from 'path' +import { DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants' const expect = chai.expect -async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) { - const resolutions = [ 240, 360, 480, 720 ] - +async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string, resolutions = [ 240, 360, 480, 720 ]) { for (const server of servers) { const res = await getVideo(server.url, videoUUID) const videoDetails: VideoDetails = res.body @@ -41,9 +40,8 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) { const masterPlaylist = res2.text - expect(masterPlaylist).to.contain('#EXT-X-STREAM-INF:BANDWIDTH=55472,RESOLUTION=640x360,FRAME-RATE=25') - for (const resolution of resolutions) { + expect(masterPlaylist).to.match(new RegExp('#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + ',FRAME-RATE=\\d+')) expect(masterPlaylist).to.contain(`${resolution}.m3u8`) } } @@ -70,11 +68,21 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) { describe('Test HLS videos', function () { let servers: ServerInfo[] = [] let videoUUID = '' + let videoAudioUUID = '' before(async function () { this.timeout(120000) - servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: true, hls: { enabled: true } } }) + const configOverride = { + transcoding: { + enabled: true, + allow_audio_files: true, + hls: { + enabled: true + } + } + } + servers = await flushAndRunMultipleServers(2, configOverride) // Get the access tokens await setAccessTokensToServers(servers) @@ -86,16 +94,25 @@ describe('Test HLS videos', function () { it('Should upload a video and transcode it to HLS', async function () { this.timeout(120000) - { - const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' }) - videoUUID = res.body.video.uuid - } + const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' }) + videoUUID = res.body.video.uuid await waitJobs(servers) await checkHlsPlaylist(servers, videoUUID) }) + it('Should upload an audio file and transcode it to HLS', async function () { + this.timeout(120000) + + const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video audio', fixture: 'sample.ogg' }) + videoAudioUUID = res.body.video.uuid + + await waitJobs(servers) + + await checkHlsPlaylist(servers, videoAudioUUID, [ DEFAULT_AUDIO_RESOLUTION ]) + }) + it('Should update the video', async function () { await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' }) @@ -104,13 +121,15 @@ describe('Test HLS videos', function () { await checkHlsPlaylist(servers, videoUUID) }) - it('Should delete the video', async function () { + it('Should delete videos', async function () { await removeVideo(servers[0].url, servers[0].accessToken, videoUUID) + await removeVideo(servers[0].url, servers[0].accessToken, videoAudioUUID) await waitJobs(servers) for (const server of servers) { await getVideo(server.url, videoUUID, 404) + await getVideo(server.url, videoAudioUUID, 404) } }) diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index cfd0c8430..90ade1652 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts @@ -14,6 +14,7 @@ import { getMyVideos, getVideo, getVideosList, + makeGetRequest, root, ServerInfo, setAccessTokensToServers, @@ -365,6 +366,56 @@ describe('Test video transcoding', function () { expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false }) + it('Should merge an audio file with the preview file', async function () { + this.timeout(60000) + + const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } + await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributesArg) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getVideosList(server.url) + + const video = res.body.data.find(v => v.name === 'audio_with_preview') + const res2 = await getVideo(server.url, video.id) + const videoDetails: VideoDetails = res2.body + + expect(videoDetails.files).to.have.lengthOf(1) + + await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: 200 }) + await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: 200 }) + + const magnetUri = videoDetails.files[ 0 ].magnetUri + expect(magnetUri).to.contain('.mp4') + } + }) + + it('Should upload an audio file and choose a default background image', async function () { + this.timeout(60000) + + const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' } + await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributesArg) + + await waitJobs(servers) + + for (const server of servers) { + const res = await getVideosList(server.url) + + const video = res.body.data.find(v => v.name === 'audio_without_preview') + const res2 = await getVideo(server.url, video.id) + const videoDetails = res2.body + + expect(videoDetails.files).to.have.lengthOf(1) + + await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: 200 }) + await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: 200 }) + + const magnetUri = videoDetails.files[ 0 ].magnetUri + expect(magnetUri).to.contain('.mp4') + } + }) + after(async function () { await cleanupTests(servers) }) diff --git a/server/tests/fixtures/sample.ogg b/server/tests/fixtures/sample.ogg new file mode 100644 index 000000000..0d7f43eb7 Binary files /dev/null and b/server/tests/fixtures/sample.ogg differ -- cgit v1.2.3 From 80b8ad2a7084c206efb194ddfbea13df860f0c5f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 17 May 2019 15:51:42 +0200 Subject: Update travis ffmpeg patch version --- .travis.yml | 4 ++-- server/tests/api/videos/multiple-servers.ts | 4 ++-- server/tests/api/videos/services.ts | 4 ++-- server/tests/api/videos/video-hls.ts | 4 ++++ server/tests/fixtures/preview.jpg | Bin 4215 -> 6868 bytes server/tests/fixtures/video_short1-preview.webm.jpg | Bin 10181 -> 22654 bytes 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5fa41fb43..8b3ec94d9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,8 +29,8 @@ install: - CC=gcc-4.9 CXX=g++-4.9 yarn install before_script: - - wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.0.2-64bit-static.tar.xz" - - tar xf ffmpeg-release-4.0.2-64bit-static.tar.xz + - wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.0.3-64bit-static.tar.xz" + - tar xf ffmpeg-release-4.0.3-64bit-static.tar.xz - mkdir -p $HOME/bin - cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin - export PATH=$HOME/bin:$PATH diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts index 09b461200..e9625e5f7 100644 --- a/server/tests/api/videos/multiple-servers.ts +++ b/server/tests/api/videos/multiple-servers.ts @@ -215,7 +215,7 @@ describe('Test multiple servers', function () { files: [ { resolution: 240, - size: 187000 + size: 189000 }, { resolution: 360, @@ -223,7 +223,7 @@ describe('Test multiple servers', function () { }, { resolution: 480, - size: 383000 + size: 384000 }, { resolution: 720, diff --git a/server/tests/api/videos/services.ts b/server/tests/api/videos/services.ts index 38e232e5f..17172331f 100644 --- a/server/tests/api/videos/services.ts +++ b/server/tests/api/videos/services.ts @@ -41,8 +41,8 @@ describe('Test services', function () { expect(res.body.width).to.equal(560) expect(res.body.height).to.equal(315) expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl) - expect(res.body.thumbnail_width).to.equal(560) - expect(res.body.thumbnail_height).to.equal(315) + expect(res.body.thumbnail_width).to.equal(850) + expect(res.body.thumbnail_height).to.equal(480) }) it('Should have a valid oEmbed response with small max height query', async function () { diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts index eacd9ab79..39178bb1a 100644 --- a/server/tests/api/videos/video-hls.ts +++ b/server/tests/api/videos/video-hls.ts @@ -114,6 +114,8 @@ describe('Test HLS videos', function () { }) it('Should update the video', async function () { + this.timeout(10000) + await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' }) await waitJobs(servers) @@ -122,6 +124,8 @@ describe('Test HLS videos', function () { }) it('Should delete videos', async function () { + this.timeout(10000) + await removeVideo(servers[0].url, servers[0].accessToken, videoUUID) await removeVideo(servers[0].url, servers[0].accessToken, videoAudioUUID) diff --git a/server/tests/fixtures/preview.jpg b/server/tests/fixtures/preview.jpg index c40ece838..cb5692281 100644 Binary files a/server/tests/fixtures/preview.jpg and b/server/tests/fixtures/preview.jpg differ diff --git a/server/tests/fixtures/video_short1-preview.webm.jpg b/server/tests/fixtures/video_short1-preview.webm.jpg index d2a068b78..157d3ca9a 100644 Binary files a/server/tests/fixtures/video_short1-preview.webm.jpg and b/server/tests/fixtures/video_short1-preview.webm.jpg differ -- cgit v1.2.3 From 820d79c8ac7ba144d9357320c49b85b980387d43 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 21 May 2019 09:53:03 +0200 Subject: Fix OGG bug with firefox --- server/helpers/express-utils.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts index e0a1d56a5..00f3f198b 100644 --- a/server/helpers/express-utils.ts +++ b/server/helpers/express-utils.ts @@ -74,7 +74,18 @@ function createReqFiles ( }, filename: async (req, file, cb) => { - const extension = mimeTypes[ file.mimetype ] || extname(file.originalname) + let extension: string + const fileExtension = extname(file.originalname) + const extensionFromMimetype = mimeTypes[ file.mimetype ] + + // Take the file extension if we don't understand the mime type + // We have the OGG/OGV exception too because firefox sends a bad mime type when sending an OGG file + if (fileExtension === '.ogg' || fileExtension === '.ogv' || !extensionFromMimetype) { + extension = fileExtension + } else { + extension = extensionFromMimetype + } + let randomString = '' try { -- cgit v1.2.3 From 618750486ee2732e0ad3525349e4d42f29e1803e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 21 May 2019 10:04:52 +0200 Subject: Fix icon color on audio upload --- client/src/app/shared/buttons/button.component.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss index 7ec77f4c9..99d7f51c1 100644 --- a/client/src/app/shared/buttons/button.component.scss +++ b/client/src/app/shared/buttons/button.component.scss @@ -4,6 +4,11 @@ .action-button { @include peertube-button-link; @include button-with-icon(21px, 0, -2px); + + // FIXME: Firefox does not apply global .orange-button icon color + &.orange-button { + @include apply-svg-color(#fff) + } } // In a table, try to minimize the space taken by this button -- cgit v1.2.3