1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-05 10:49:28 +02:00

Separate HLS audio and video streams

Allows:
  * The HLS player to propose an "Audio only" resolution
  * The live to output an "Audio only" resolution
  * The live to ingest and output an "Audio only" stream

 This feature is under a config for VOD videos and is enabled by default for lives

 In the future we can imagine:
  * To propose multiple audio streams for a specific video
  * To ingest an audio only VOD and just output an audio only "video"
    (the player would play the audio file and PeerTube would not
    generate additional resolutions)

This commit introduce a new way to download videos:
 * Add "/download/videos/generate/:videoId" endpoint where PeerTube can
   mux an audio only and a video only file to a mp4 container
 * The download client modal introduces a new default panel where the
   user can choose resolutions it wants to download
This commit is contained in:
Chocobozzz 2024-07-23 16:38:51 +02:00 committed by Chocobozzz
parent e77ba2dfbc
commit 816f346a60
186 changed files with 5748 additions and 2807 deletions

View file

@ -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<UserExportModel> {
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()
}
// ---------------------------------------------------------------------------

View file

@ -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()
}
})
}

View file

@ -91,6 +91,8 @@ export class VideoTableAttributes {
'videoId',
'width',
'height',
'formatFlags',
'streams',
'storage'
]
}

View file

@ -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<VideoFileModel> {
@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<VideoFileModel> {
return extractVideo(this.getVideoOrStreamingPlaylist())
}
// ---------------------------------------------------------------------------
isAudio () {
return this.resolution === VideoResolution.H_NOVIDEO
}
@ -515,6 +534,14 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
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<VideoFileModel> {
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<VideoFileModel> {
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<VideoFileModel> {
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<VideoFileModel> {
height: this.height || this.resolution,
width: this.width,
size: this.size,
fps: this.fps
fps: this.fps,
attachment
}
}
}

View file

@ -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<VideoSourceModel> {
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,

View file

@ -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<VideoStreamingPl
hashes.push(sha1(`${P2P_MEDIA_LOADER_PEER_VERSION}${playlistUrl}+V${i}`))
}
logger.debug('Assigned P2P Media Loader info hashes', { playlistUrl, hashes })
return hashes
}
@ -292,6 +296,26 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
// ---------------------------------------------------------------------------
hasAudioAndVideoSplitted (this: MStreamingPlaylistFiles) {
// We need at least 2 files to have audio and video splitted
if (this.VideoFiles.length === 1) return false
let hasAudio = false
let hasVideo = false
for (const file of this.VideoFiles) {
// File contains both streams: audio and video is not splitted
if (file.hasAudio() && file.hasVideo()) return false
if (file.resolution === VideoResolution.H_NOVIDEO) hasAudio = true
else if (file.hasVideo()) hasVideo = true
if (hasVideo && hasAudio) return true
}
return false
}
getStringType () {
if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'

View file

@ -1,5 +1,4 @@
import { buildVideoEmbedPath, buildVideoWatchPath, maxBy, minBy, pick, wait } from '@peertube/peertube-core-utils'
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg'
import { buildVideoEmbedPath, buildVideoWatchPath, maxBy, pick, sortBy, wait } from '@peertube/peertube-core-utils'
import {
FileStorage,
ResultList,
@ -8,6 +7,8 @@ import {
Video,
VideoDetails,
VideoFile,
VideoFileStream,
VideoFileStreamType,
VideoInclude,
VideoIncludeType,
VideoObject,
@ -73,7 +74,7 @@ import {
isVideoStateValid,
isVideoSupportValid
} from '../../helpers/custom-validators/videos.js'
import { logger } from '../../helpers/logger.js'
import { logger, loggerTagsFactory } from '../../helpers/logger.js'
import { CONFIG } from '../../initializers/config.js'
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants.js'
import { sendDeleteVideo } from '../../lib/activitypub/send/index.js'
@ -162,6 +163,8 @@ import { VideoSourceModel } from './video-source.js'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
import { VideoTagModel } from './video-tag.js'
const lTags = loggerTagsFactory('video')
export enum ScopeNames {
FOR_API = 'FOR_API',
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
@ -1735,8 +1738,43 @@ export class VideoModel extends SequelizeModel<VideoModel> {
return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
}
getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], property: 'resolution') => MVideoFile) {
const files = this.getAllFiles()
// ---------------------------------------------------------------------------
getMaxQualityAudioAndVideoFiles <T extends MVideoWithFile> (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<T extends MVideoWithFile> (
this: T,
streamFilter: VideoFileStreamType
): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
return this.getQualityFileBy(streamFilter, maxBy)
}
getMaxQualityBytes <T extends MVideoWithFile> (this: T) {
const { videoFile, separatedAudioFile } = this.getMaxQualityAudioAndVideoFiles()
let size = videoFile.size
if (separatedAudioFile) size += separatedAudioFile.size
return size
}
getQualityFileBy<T extends MVideoWithFile> (
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<VideoModel> {
throw new Error('File is not associated to a video of a playlist')
}
getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
return this.getQualityFileBy(maxBy)
// ---------------------------------------------------------------------------
getMaxFPS () {
return this.getMaxQualityFile(VideoFileStream.VIDEO).fps
}
getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
return this.getQualityFileBy(minBy)
getMaxResolution () {
return this.getMaxQualityFile(VideoFileStream.VIDEO).resolution
}
getWebVideoFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
hasAudio () {
return !!this.getMaxQualityFile(VideoFileStream.AUDIO)
}
// ---------------------------------------------------------------------------
getWebVideoFileMinResolution<T extends MVideoWithFile> (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<VideoModel> {
// ---------------------------------------------------------------------------
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<VideoModel> {
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<VideoModel> {
.concat(toAdd)
}
// ---------------------------------------------------------------------------
removeWebVideoFile (videoFile: MVideoFile, isRedundancy = false) {
const filePath = isRedundancy
? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
@ -1989,6 +2021,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
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<VideoModel> {
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<VideoModel> {
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<VideoModel> {
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<VideoModel> {
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