Init commit

This commit is contained in:
ShreejitPanchal
2026-05-19 19:56:02 +08:00
commit 6601501eff
4047 changed files with 2185372 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export * from './youtube-dl-cli.js'
export * from './youtube-dl-info-builder.js'
export * from './youtube-dl-wrapper.js'

View File

@@ -0,0 +1,284 @@
import { randomInt } from '@peertube/peertube-core-utils'
import { VideoResolution, VideoResolutionType } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js'
import { execa, Options as ExecaNodeOptions } from 'execa'
import { ensureDir, pathExists } from 'fs-extra/esm'
import { chmod, writeFile } from 'fs/promises'
import { OptionsOfBufferResponseBody } from 'got'
import { dirname, join } from 'path'
import { logger, loggerTagsFactory } from '../logger.js'
import { getProxy, isProxyEnabled } from '../proxy.js'
import { isBinaryResponse, unsafeSSRFGot } from '../requests.js'
type ProcessOptions = Pick<ExecaNodeOptions, 'cwd' | 'maxBuffer'>
const lTags = loggerTagsFactory('youtube-dl')
const youtubeDLBinaryPath = join(CONFIG.STORAGE.BIN_DIR, CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.NAME)
export class YoutubeDLCLI {
static async safeGet () {
if (!await pathExists(youtubeDLBinaryPath)) {
await ensureDir(dirname(youtubeDLBinaryPath))
await this.updateYoutubeDLBinary()
}
return new YoutubeDLCLI()
}
static async updateYoutubeDLBinary () {
const url = CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.URL
logger.info('Updating youtubeDL binary from %s.', url, lTags())
const gotOptions: OptionsOfBufferResponseBody = {
context: { bodyKBLimit: 100_000 },
responseType: 'buffer' as 'buffer'
}
if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
gotOptions.headers = {
authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
}
}
try {
let gotResult = await unsafeSSRFGot(url, gotOptions)
if (!isBinaryResponse(gotResult)) {
const json = JSON.parse(gotResult.body.toString())
const latest = json.filter(release => release.prerelease === false)[0]
if (!latest) throw new Error('Cannot find latest release')
const releaseName = CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.NAME
const releaseAsset = latest.assets.find(a => a.name === releaseName)
if (!releaseAsset) throw new Error(`Cannot find appropriate release with name ${releaseName} in release assets`)
gotResult = await unsafeSSRFGot(releaseAsset.browser_download_url, gotOptions)
}
if (!isBinaryResponse(gotResult)) {
throw new Error('Not a binary response')
}
await writeFile(youtubeDLBinaryPath, gotResult.body)
if (!CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.PYTHON_PATH) {
await chmod(youtubeDLBinaryPath, '744')
}
logger.info('youtube-dl updated %s.', youtubeDLBinaryPath, lTags())
} catch (err) {
logger.error('Cannot update youtube-dl from %s.', url, { err, ...lTags() })
}
}
static getYoutubeDLVideoFormat (enabledResolutions: VideoResolutionType[], useBestFormat: boolean) {
/**
* list of format selectors in order or preference
* see https://github.com/ytdl-org/youtube-dl#format-selection
*
* case #1 asks for a mp4 using h264 (avc1) and the exact resolution in the hope
* of being able to do a "quick-transcode"
* case #2 is the first fallback. No "quick-transcode" means we can get anything else (like vp9)
* case #3 is the resolution-degraded equivalent of #1, and already a pretty safe fallback
*
* in any case we avoid AV1, see https://github.com/Chocobozzz/PeerTube/issues/3499
*/
let result: string[] = []
if (!useBestFormat) {
const resolution = enabledResolutions.length === 0
? VideoResolution.H_720P
: Math.max(...enabledResolutions)
result = [
`bestvideo[vcodec^=avc1][height=${resolution}]+bestaudio[ext=m4a]`, // case #1
`bestvideo[vcodec!*=av01][vcodec!*=vp9.2][height=${resolution}]+bestaudio`, // case #2
`bestvideo[vcodec^=avc1][height<=${resolution}]+bestaudio[ext=m4a]` // case #
]
}
return result.concat([
'bestvideo[vcodec!*=av01][vcodec!*=vp9.2]+bestaudio',
'best[vcodec!*=av01][vcodec!*=vp9.2]', // case fallback for known formats
'bestvideo[ext=mp4]+bestaudio[ext=m4a]',
'best' // Ultimate fallback
]).join('/')
}
private constructor () {
}
download (options: {
url: string
format: string
output: string
processOptions: ProcessOptions
timeout?: number
additionalYoutubeDLArgs?: string[]
}) {
let args = options.additionalYoutubeDLArgs || []
args = args.concat([ '--merge-output-format', 'mp4', '-f', options.format, '-o', options.output ])
return this.run({
url: options.url,
processOptions: options.processOptions,
timeout: options.timeout,
args
})
}
async getInfo (options: {
url: string
format: string
processOptions: ProcessOptions
additionalYoutubeDLArgs?: string[]
}) {
const { url, format, additionalYoutubeDLArgs = [], processOptions } = options
const completeArgs = additionalYoutubeDLArgs.concat([ '--dump-json', '-f', format ])
const data = await this.run({ url, args: completeArgs, processOptions })
if (!data) return undefined
const info = data.map(d => JSON.parse(d))
return info.length === 1
? info[0]
: info
}
async getListInfo (options: {
url: string
latestVideosCount?: number
processOptions: ProcessOptions
}): Promise<{ upload_date: string, webpage_url: string }[]> {
const additionalYoutubeDLArgs = [ '--skip-download', '--playlist-reverse' ]
if (CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.NAME === 'yt-dlp') {
// Optimize listing videos only when using yt-dlp because it is bugged with youtube-dl when fetching a channel
additionalYoutubeDLArgs.push('--flat-playlist')
}
if (options.latestVideosCount !== undefined) {
additionalYoutubeDLArgs.push('--playlist-end', options.latestVideosCount.toString())
}
const result = await this.getInfo({
url: options.url,
format: YoutubeDLCLI.getYoutubeDLVideoFormat([], false),
processOptions: options.processOptions,
additionalYoutubeDLArgs
})
if (!result) return result
if (!Array.isArray(result)) return [ result ]
return result
}
async getSubs (options: {
url: string
format: 'vtt'
processOptions: ProcessOptions
}) {
const { url, format, processOptions } = options
const args = [ '--skip-download', '--all-subs', `--sub-format=${format}` ]
const data = await this.run({ url, args, processOptions })
const files: string[] = []
const skipString = '[info] Writing video subtitles to: '
for (let i = 0, len = data.length; i < len; i++) {
const line = data[i]
if (line.startsWith(skipString)) {
files.push(line.slice(skipString.length))
}
}
return files
}
private async run (options: {
url: string
args: string[]
timeout?: number
processOptions: ProcessOptions
}) {
const { url, args, timeout, processOptions } = options
let completeArgs = this.wrapWithJSRuntimeOptions(args)
completeArgs = this.wrapWithProxyOptions(completeArgs)
completeArgs = this.wrapWithIPOptions(completeArgs)
completeArgs = this.wrapWithFFmpegOptions(completeArgs)
const subProcessBinary = CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.PYTHON_PATH || youtubeDLBinaryPath
const subProcessArgs = [ ...completeArgs, url ]
if (subProcessBinary !== youtubeDLBinaryPath) subProcessArgs.unshift(youtubeDLBinaryPath)
const subProcess = execa(subProcessBinary, subProcessArgs, processOptions)
if (timeout) {
setTimeout(() => subProcess.kill(), timeout)
}
const output = await subProcess
logger.debug('Run youtube-dl command.', { command: output.command, ...lTags() })
return output.stdout
? output.stdout.trim().split(/\r?\n/)
: undefined
}
private wrapWithJSRuntimeOptions (args: string[]) {
if (CONFIG.IMPORT.VIDEOS.HTTP.YOUTUBE_DL_RELEASE.NAME === 'yt-dlp') {
return [ '--js-runtimes', 'node' ].concat(args)
}
return args
}
private wrapWithProxyOptions (args: string[]) {
const config = CONFIG.IMPORT.VIDEOS.HTTP.PROXIES
const configProxyEnabled = Array.isArray(config) && config.length !== 0
if (configProxyEnabled || isProxyEnabled()) {
const proxy = configProxyEnabled
? config[randomInt(0, config.length)]
: getProxy()
logger.debug('Using proxy %s for YoutubeDL', proxy, lTags())
return [ '--proxy', proxy ].concat(args)
}
return args
}
private wrapWithIPOptions (args: string[]) {
if (CONFIG.IMPORT.VIDEOS.HTTP.FORCE_IPV4) {
logger.debug('Force ipv4 for YoutubeDL')
return [ '--force-ipv4' ].concat(args)
}
return args
}
private wrapWithFFmpegOptions (args: string[]) {
if (process.env.FFMPEG_PATH) {
logger.debug('Using ffmpeg location %s for YoutubeDL', process.env.FFMPEG_PATH, lTags())
return [ '--ffmpeg-location', process.env.FFMPEG_PATH ].concat(args)
}
return args
}
}

View File

@@ -0,0 +1,213 @@
import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
import { peertubeTruncate } from '../core-utils.js'
import { isUrlValid } from '../custom-validators/activitypub/misc.js'
import { isArray } from '../custom-validators/misc.js'
export type YoutubeDLInfo = {
name?: string
description?: string
category?: number
language?: string
licence?: number
nsfw?: boolean
tags?: string[]
thumbnailUrl?: string
ext?: string
originallyPublishedAtWithoutTime?: Date
webpageUrl?: string
urls?: string[]
chapters?: {
timecode: number
title: string
}[]
}
export class YoutubeDLInfoBuilder {
private readonly info: any
constructor (info: any) {
this.info = { ...info }
}
getInfo () {
const obj = this.buildVideoInfo(this.normalizeObject(this.info))
if (obj.name && obj.name.length < CONSTRAINTS_FIELDS.VIDEOS.NAME.min) obj.name += ' video'
return obj
}
private normalizeObject (obj: any) {
const newObj: any = {}
for (const key of Object.keys(obj)) {
// Deprecated key
if (key === 'resolution') continue
const value = obj[key]
if (typeof value === 'string') {
newObj[key] = value.normalize()
} else {
newObj[key] = value
}
}
return newObj
}
private buildOriginallyPublishedAt (obj: any) {
let originallyPublishedAt: Date = null
const uploadDateMatcher = /^(\d{4})(\d{2})(\d{2})$/.exec(obj.upload_date)
if (uploadDateMatcher) {
originallyPublishedAt = new Date()
originallyPublishedAt.setHours(0, 0, 0, 0)
const year = parseInt(uploadDateMatcher[1], 10)
// Month starts from 0
const month = parseInt(uploadDateMatcher[2], 10) - 1
const day = parseInt(uploadDateMatcher[3], 10)
originallyPublishedAt.setFullYear(year, month, day)
}
return originallyPublishedAt
}
private buildVideoInfo (obj: any): YoutubeDLInfo {
return {
name: this.titleTruncation(obj.title || obj.fulltitle),
description: this.descriptionTruncation(obj.description),
category: this.getCategory(obj.categories),
licence: this.getLicence(obj.license),
language: this.getLanguage(obj.language),
nsfw: this.isNSFW(obj),
tags: this.getTags(obj.tags),
thumbnailUrl: obj.thumbnail || undefined,
urls: this.buildAvailableUrl(obj),
originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
ext: obj.ext,
webpageUrl: obj.webpage_url,
chapters: isArray(obj.chapters)
? obj.chapters.map((c: { start_time: number, title: string }) => {
return {
timecode: c.start_time,
title: c.title.slice(0, CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max)
}
})
: []
}
}
private buildAvailableUrl (obj: any) {
const urls: string[] = []
if (obj.url) urls.push(obj.url)
if (obj.urls) {
if (Array.isArray(obj.urls)) urls.push(...obj.urls)
else urls.push(obj.urls)
}
const formats = Array.isArray(obj.formats)
? obj.formats
: []
for (const format of formats) {
if (!format.url) continue
urls.push(format.url)
}
const thumbnails = Array.isArray(obj.thumbnails)
? obj.thumbnails
: []
for (const thumbnail of thumbnails) {
if (!thumbnail.url) continue
urls.push(thumbnail.url)
}
if (obj.thumbnail) urls.push(obj.thumbnail)
for (const subtitleKey of Object.keys(obj.subtitles || {})) {
const subtitles = obj.subtitles[subtitleKey]
if (!Array.isArray(subtitles)) continue
for (const subtitle of subtitles) {
if (!subtitle.url) continue
urls.push(subtitle.url)
}
}
return urls.filter(u => u && isUrlValid(u))
}
private titleTruncation (title: string) {
return peertubeTruncate(title, {
length: CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
separator: /,? +/,
omission: ' […]'
})
}
private descriptionTruncation (description: string) {
if (!description || description.length < CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.min) return undefined
return peertubeTruncate(description, {
length: CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max,
separator: /,? +/,
omission: ' […]'
})
}
private isNSFW (info: any) {
return info?.age_limit >= 16
}
private getTags (tags: string[]) {
if (Array.isArray(tags) === false) return []
return tags
.filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
.map(t => t.normalize())
.slice(0, 5)
}
private getLicence (licence: string) {
if (!licence) return undefined
if (licence.includes('Creative Commons Attribution')) return 1
for (const key of Object.keys(VIDEO_LICENCES)) {
const peertubeLicence = VIDEO_LICENCES[key]
if (peertubeLicence.toLowerCase() === licence.toLowerCase()) return parseInt(key, 10)
}
return undefined
}
private getCategory (categories: string[]) {
if (!categories) return undefined
const categoryString = categories[0]
if (!categoryString || typeof categoryString !== 'string') return undefined
if (categoryString === 'News & Politics') return 11
for (const key of Object.keys(VIDEO_CATEGORIES)) {
const category = VIDEO_CATEGORIES[key]
if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10)
}
return undefined
}
private getLanguage (language: string) {
return VIDEO_LANGUAGES[language] ? language : undefined
}
}

View File

@@ -0,0 +1,248 @@
import { VideoResolutionType } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js'
import { ExecaError } from 'execa'
import { move, pathExists, remove } from 'fs-extra/esm'
import { readdir } from 'fs/promises'
import { dirname, join } from 'path'
import { inspect } from 'util'
import { isVideoFileExtnameValid } from '../custom-validators/videos.js'
import { t } from '../i18n.js'
import { logger, loggerTagsFactory } from '../logger.js'
import { generateVideoImportTmpPath } from '../utils.js'
import { YoutubeDLCLI } from './youtube-dl-cli.js'
import { YoutubeDLInfo, YoutubeDLInfoBuilder } from './youtube-dl-info-builder.js'
const lTags = loggerTagsFactory('youtube-dl')
export const YoutubeDlImportErrorCode = {
FETCH_ERROR: 0,
NOT_ONLY_UNICAST_URL: 1,
SKIP_PUBLICATION_DATE: 2,
IS_LIVE: 3
}
export type YoutubeDlImportErrorCodeType = typeof YoutubeDlImportErrorCode[keyof typeof YoutubeDlImportErrorCode]
export class YoutubeDlImportError extends Error {
static fromError (options: {
err: Error
code: YoutubeDlImportErrorCodeType
message?: string
}) {
const { err, code, message } = options
const ytDlErr = new this({ message: message ?? err.message, code })
ytDlErr.cause = err
return ytDlErr
}
code: YoutubeDlImportErrorCodeType
constructor ({ message, code }) {
super(message)
this.code = code
}
isUnavailableVideoError () {
const stderr = this.getStderr()
if (stderr.includes('Video unavailable') || stderr.includes(' 429 ')) {
return true
}
return false
}
isRateLimitError () {
const stderr = this.getStderr()
if (stderr.includes('Sign in to confirm youre not a bot')) {
return true
}
return false
}
private getStderr () {
const stderr = (this.cause as ExecaError)?.stderr
if (typeof stderr === 'string') return stderr
return ''
}
}
export type YoutubeDLSubs = {
language: string
filename: string
path: string
}[]
const processOptions = {
maxBuffer: 1024 * 1024 * 30 // 30MB
}
export class YoutubeDLWrapper {
constructor (
private readonly url: string,
private readonly enabledResolutions: VideoResolutionType[],
private readonly useBestFormat: boolean
) {
}
async getInfoForDownload (options: {
userLanguage: string
youtubeDLArgs?: string[]
}): Promise<YoutubeDLInfo> {
const { userLanguage, youtubeDLArgs = [] } = options
const youtubeDL = await YoutubeDLCLI.safeGet()
try {
const info = await youtubeDL.getInfo({
url: this.url,
format: YoutubeDLCLI.getYoutubeDLVideoFormat(this.enabledResolutions, this.useBestFormat),
additionalYoutubeDLArgs: youtubeDLArgs,
processOptions
})
if (!info) {
throw new YoutubeDlImportError({
message: t(`Cannot fetch information from import for URL {targetUrl}`, userLanguage, { targetUrl: this.url }),
code: YoutubeDlImportErrorCode.FETCH_ERROR
})
}
if (info.is_live === true || [ 'is_live', 'post_live', 'is_upcoming' ].includes(info.live_status)) {
throw new YoutubeDlImportError({
message: t('Cannot download a live streaming for URL {targetUrl}', userLanguage, { targetUrl: this.url }),
code: YoutubeDlImportErrorCode.IS_LIVE
})
}
const infoBuilder = new YoutubeDLInfoBuilder(info)
return infoBuilder.getInfo()
} catch (err) {
throw YoutubeDlImportError.fromError({
err,
message: t(`Cannot get info from {targetUrl}`, userLanguage, { targetUrl: this.url }),
code: YoutubeDlImportErrorCode.FETCH_ERROR
})
}
}
async getInfoForListImport (options: {
userLanguage: string
latestVideosCount?: number
}) {
const { userLanguage } = options
const youtubeDL = await YoutubeDLCLI.safeGet()
const list = await youtubeDL.getListInfo({
url: this.url,
latestVideosCount: options.latestVideosCount,
processOptions
})
if (!Array.isArray(list)) {
logger.debug(`List info from youtube-dl is not an array for ${this.url}.`, { info: inspect(list), ...lTags() })
throw new YoutubeDlImportError({
message: t(`YoutubeDL could not get list info from ${this.url}: ${inspect(list)}`, userLanguage, { targetUrl: this.url }),
code: YoutubeDlImportErrorCode.FETCH_ERROR
})
}
return list.map(info => info.webpage_url)
}
async getSubtitles (): Promise<YoutubeDLSubs> {
const cwd = CONFIG.STORAGE.TMP_DIR
const youtubeDL = await YoutubeDLCLI.safeGet()
const files = await youtubeDL.getSubs({ url: this.url, format: 'vtt', processOptions: { cwd } })
if (!files) return []
logger.debug('Get subtitles from youtube dl.', { url: this.url, files, ...lTags() })
const subtitles = files.reduce((acc, filename) => {
const matched = filename.match(/\.([a-z]{2})(-[a-z]+)?\.(vtt|ttml)/i)
if (!matched?.[1]) return acc
return [
...acc,
{
language: matched[1],
path: join(cwd, filename),
filename
}
]
}, [])
return subtitles
}
async downloadVideo (fileExt: string, timeout: number): Promise<string> {
// Leave empty the extension, youtube-dl will add it
const pathWithoutExtension = generateVideoImportTmpPath(this.url, '')
logger.info('Importing youtubeDL video %s to %s', this.url, pathWithoutExtension, lTags())
const youtubeDL = await YoutubeDLCLI.safeGet()
try {
await youtubeDL.download({
url: this.url,
format: YoutubeDLCLI.getYoutubeDLVideoFormat(this.enabledResolutions, this.useBestFormat),
output: pathWithoutExtension,
timeout,
processOptions
})
// If youtube-dl did not guess an extension for our file, just use .mp4 as default
if (await pathExists(pathWithoutExtension)) {
await move(pathWithoutExtension, pathWithoutExtension + '.mp4')
}
const path = await this.guessVideoPathWithExtension(pathWithoutExtension, fileExt)
if (!path) {
const directoryContent = await readdir(dirname(pathWithoutExtension))
throw new Error(`Cannot guess path of ${pathWithoutExtension}. Directory content: ${directoryContent.join(', ')}`)
}
return path
} catch (err) {
this.guessVideoPathWithExtension(pathWithoutExtension, fileExt)
.then(path => {
logger.debug('Error in youtube-dl import, deleting file if exists.', { err, pathToDelete: path, ...lTags() })
if (path) return remove(path)
})
.catch(innerErr => logger.error('Cannot remove file in youtubeDL error.', { innerErr, ...lTags() }))
throw err
}
}
private async guessVideoPathWithExtension (tmpPath: string, sourceExt: string) {
if (!isVideoFileExtnameValid(sourceExt)) {
throw new Error('Invalid video extension ' + sourceExt)
}
const extensions = [ sourceExt, '.mp4', '.mkv', '.webm' ]
for (const extension of extensions) {
const path = tmpPath + extension
if (await pathExists(path)) return path
}
return undefined
}
}