はじまりの大地
このコミットが含まれているのは:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@peertube/peertube-core-utils",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"files": [ "dist" ],
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"peertube:tsx": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AbusePredefinedReasons, AbusePredefinedReasonsString, AbusePredefinedReasonsType } from '@peertube/peertube-models'
|
||||
|
||||
export const abusePredefinedReasonsMap: {
|
||||
[key in AbusePredefinedReasonsString]: AbusePredefinedReasonsType
|
||||
} = {
|
||||
violentOrRepulsive: AbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
|
||||
hatefulOrAbusive: AbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
|
||||
spamOrMisleading: AbusePredefinedReasons.SPAM_OR_MISLEADING,
|
||||
privacy: AbusePredefinedReasons.PRIVACY,
|
||||
rights: AbusePredefinedReasons.RIGHTS,
|
||||
serverRules: AbusePredefinedReasons.SERVER_RULES,
|
||||
thumbnails: AbusePredefinedReasons.THUMBNAILS,
|
||||
captions: AbusePredefinedReasons.CAPTIONS
|
||||
} as const
|
||||
@@ -0,0 +1 @@
|
||||
export * from './abuse-predefined-reasons.js'
|
||||
@@ -0,0 +1,65 @@
|
||||
export function findCommonElement <T> (array1: T[], array2: T[]) {
|
||||
for (const a of array1) {
|
||||
for (const b of array2) {
|
||||
if (a === b) return a
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Avoid conflict with other toArray() functions
|
||||
export function arrayify <T> (element: T | T[]) {
|
||||
if (Array.isArray(element)) return element
|
||||
|
||||
return [ element ]
|
||||
}
|
||||
|
||||
// Avoid conflict with other uniq() functions
|
||||
export function uniqify <T> (elements: T[]) {
|
||||
return Array.from(new Set(elements))
|
||||
}
|
||||
|
||||
// Thanks: https://stackoverflow.com/a/12646864
|
||||
export function shuffle <T> (elements: T[]) {
|
||||
const shuffled = [ ...elements ]
|
||||
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
|
||||
[ shuffled[i], shuffled[j] ] = [ shuffled[j], shuffled[i] ]
|
||||
}
|
||||
|
||||
return shuffled
|
||||
}
|
||||
|
||||
export function sortBy (obj: any[], key1: string, key2?: string) {
|
||||
return obj.sort((a, b) => {
|
||||
const elem1 = key2 ? a[key1][key2] : a[key1]
|
||||
const elem2 = key2 ? b[key1][key2] : b[key1]
|
||||
|
||||
if (elem1 < elem2) return -1
|
||||
if (elem1 === elem2) return 0
|
||||
return 1
|
||||
})
|
||||
}
|
||||
|
||||
export function maxBy <T> (arr: T[], property: keyof T) {
|
||||
let result: T
|
||||
|
||||
for (const obj of arr) {
|
||||
if (!result || result[property] < obj[property]) result = obj
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function minBy <T> (arr: T[], property: keyof T) {
|
||||
let result: T
|
||||
|
||||
for (const obj of arr) {
|
||||
if (!result || result[property] > obj[property]) result = obj
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
function isToday (d: Date) {
|
||||
const today = new Date()
|
||||
|
||||
return areDatesEqual(d, today)
|
||||
}
|
||||
|
||||
function isYesterday (d: Date) {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
return areDatesEqual(d, yesterday)
|
||||
}
|
||||
|
||||
function isThisWeek (d: Date) {
|
||||
const minDateOfThisWeek = new Date()
|
||||
minDateOfThisWeek.setHours(0, 0, 0)
|
||||
|
||||
// getDay() -> Sunday - Saturday : 0 - 6
|
||||
// We want to start our week on Monday
|
||||
let dayOfWeek = minDateOfThisWeek.getDay() - 1
|
||||
if (dayOfWeek < 0) dayOfWeek = 6 // Sunday
|
||||
|
||||
minDateOfThisWeek.setDate(minDateOfThisWeek.getDate() - dayOfWeek)
|
||||
|
||||
return d >= minDateOfThisWeek
|
||||
}
|
||||
|
||||
function isThisMonth (d: Date) {
|
||||
const thisMonth = new Date().getMonth()
|
||||
|
||||
return d.getMonth() === thisMonth
|
||||
}
|
||||
|
||||
function isLastMonth (d: Date) {
|
||||
const now = new Date()
|
||||
|
||||
return getDaysDifferences(now, d) <= 30
|
||||
}
|
||||
|
||||
function isLastWeek (d: Date) {
|
||||
const now = new Date()
|
||||
|
||||
return getDaysDifferences(now, d) <= 7
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const timecodeRegexString = `(\\d+[h:])?(\\d+[m:])?\\d+s?`
|
||||
|
||||
function timeToInt (time: number | string) {
|
||||
if (!time) return 0
|
||||
if (typeof time === 'number') return Math.floor(time)
|
||||
|
||||
// Try with 00h00m00s format first
|
||||
const reg = new RegExp(`^((?<hours>\\d+)h)?((?<minutes>\\d+)m)?((?<seconds>\\d+)s?)?$`)
|
||||
const matches = time.match(reg)
|
||||
|
||||
if (matches) {
|
||||
const hours = parseInt(matches.groups['hours'] || '0', 10)
|
||||
const minutes = parseInt(matches.groups['minutes'] || '0', 10)
|
||||
const seconds = parseInt(matches.groups['seconds'] || '0', 10)
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
// ':' format fallback
|
||||
const parts = time.split(':').reverse()
|
||||
|
||||
const iMultiplier = {
|
||||
0: 1,
|
||||
1: 60,
|
||||
2: 3600
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const partInt = parseInt(parts[i], 10)
|
||||
if (isNaN(partInt)) return 0
|
||||
|
||||
result += iMultiplier[i] * partInt
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function secondsToTime (options: {
|
||||
seconds: number
|
||||
format: 'short' | 'full' | 'locale-string' // default 'short'
|
||||
symbol?: string
|
||||
} | number) {
|
||||
let seconds: number
|
||||
let format: 'short' | 'full' | 'locale-string' = 'short'
|
||||
let symbol: string
|
||||
|
||||
if (typeof options === 'number') {
|
||||
seconds = options
|
||||
} else {
|
||||
seconds = options.seconds
|
||||
format = options.format ?? 'short'
|
||||
symbol = options.symbol
|
||||
}
|
||||
|
||||
let time = ''
|
||||
|
||||
if (seconds === 0 && format !== 'full') return '0s'
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (format === 'locale-string') return value.toLocaleString()
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const hourSymbol = (symbol || 'h')
|
||||
const minuteSymbol = (symbol || 'm')
|
||||
const secondsSymbol = format === 'full' ? '' : 's'
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
if (hours >= 1 && hours < 10 && format === 'full') time = '0' + hours + hourSymbol
|
||||
else if (hours >= 1) time = formatNumber(hours) + hourSymbol
|
||||
else if (format === 'full') time = '00' + hourSymbol
|
||||
|
||||
seconds %= 3600
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes >= 1 && minutes < 10 && format === 'full') time += '0' + minutes + minuteSymbol
|
||||
else if (minutes >= 1) time += formatNumber(minutes) + minuteSymbol
|
||||
else if (format === 'full') time += '00' + minuteSymbol
|
||||
|
||||
seconds = Math.round(seconds) % 60
|
||||
if (seconds >= 1 && seconds < 10 && format === 'full') time += '0' + seconds + secondsSymbol
|
||||
else if (seconds >= 1) time += formatNumber(seconds) + secondsSymbol
|
||||
else if (format === 'full') time += '00'
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
function millisecondsToTime (options: {
|
||||
seconds: number
|
||||
format: 'short' | 'full' | 'locale-string' // default 'short'
|
||||
symbol?: string
|
||||
} | number) {
|
||||
return secondsToTime(typeof options === 'number' ? options / 1000 : { ...options, seconds: options.seconds / 1000 })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
isYesterday,
|
||||
isThisWeek,
|
||||
isThisMonth,
|
||||
isToday,
|
||||
isLastMonth,
|
||||
isLastWeek,
|
||||
timeToInt,
|
||||
secondsToTime,
|
||||
millisecondsToTime
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function areDatesEqual (d1: Date, d2: Date) {
|
||||
return d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate()
|
||||
}
|
||||
|
||||
function getDaysDifferences (d1: Date, d2: Date) {
|
||||
return (d1.getTime() - d2.getTime()) / (86400000)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './array.js'
|
||||
export * from './random.js'
|
||||
export * from './date.js'
|
||||
export * from './number.js'
|
||||
export * from './object.js'
|
||||
export * from './regexp.js'
|
||||
export * from './time.js'
|
||||
export * from './promises.js'
|
||||
export * from './url.js'
|
||||
export * from './version.js'
|
||||
@@ -0,0 +1,13 @@
|
||||
export function forceNumber (value: any) {
|
||||
return parseInt(value + '')
|
||||
}
|
||||
|
||||
export function isOdd (num: number) {
|
||||
return (num % 2) !== 0
|
||||
}
|
||||
|
||||
export function toEven (num: number) {
|
||||
if (isOdd(num)) return num + 1
|
||||
|
||||
return num
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
function pick <O extends object, K extends keyof O> (object: O, keys: K[]): Pick<O, K> {
|
||||
const result: any = {}
|
||||
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
||||
result[key] = object[key]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function omit <O extends object, K extends keyof O> (object: O, keys: K[]): Exclude<O, K> {
|
||||
const result: any = {}
|
||||
const keysSet = new Set(keys) as Set<string>
|
||||
|
||||
for (const [ key, value ] of Object.entries(object)) {
|
||||
if (keysSet.has(key)) continue
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function objectKeysTyped <O extends object, K extends keyof O> (object: O): K[] {
|
||||
return (Object.keys(object) as K[])
|
||||
}
|
||||
|
||||
function getKeys <O extends object, K extends keyof O> (object: O, keys: K[]): K[] {
|
||||
return (Object.keys(object) as K[]).filter(k => keys.includes(k))
|
||||
}
|
||||
|
||||
function hasKey <T extends object> (obj: T, k: keyof any): k is keyof T {
|
||||
return k in obj
|
||||
}
|
||||
|
||||
function sortObjectComparator (key: string, order: 'asc' | 'desc') {
|
||||
return (a: any, b: any) => {
|
||||
if (a[key] < b[key]) {
|
||||
return order === 'asc' ? -1 : 1
|
||||
}
|
||||
|
||||
if (a[key] > b[key]) {
|
||||
return order === 'asc' ? 1 : -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function shallowCopy <T> (o: T): T {
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(o)), o)
|
||||
}
|
||||
|
||||
function simpleObjectsDeepEqual (a: any, b: any) {
|
||||
if (a === b) return true
|
||||
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
|
||||
if (keysA.length !== keysB.length) return false
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false
|
||||
|
||||
if (!simpleObjectsDeepEqual(a[key], b[key])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export {
|
||||
pick,
|
||||
omit,
|
||||
objectKeysTyped,
|
||||
getKeys,
|
||||
hasKey,
|
||||
shallowCopy,
|
||||
sortObjectComparator,
|
||||
simpleObjectsDeepEqual
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export function isPromise <T = unknown> (value: T | Promise<T>): value is Promise<T> {
|
||||
return value && typeof (value as Promise<T>).then === 'function'
|
||||
}
|
||||
|
||||
export function isCatchable (value: any) {
|
||||
return value && typeof value.catch === 'function'
|
||||
}
|
||||
|
||||
export function timeoutPromise <T> (promise: Promise<T>, timeoutMs: number) {
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
|
||||
new Promise((_res, rej) => {
|
||||
timer = setTimeout(() => rej(new Error('Timeout')), timeoutMs)
|
||||
})
|
||||
]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
export function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
|
||||
return function promisified (): Promise<A> {
|
||||
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
func.apply(null, [ (err: any, res: A) => err ? reject(err) : resolve(res) ])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Thanks to https://gist.github.com/kumasento/617daa7e46f13ecdd9b2
|
||||
export function promisify1<T, A> (func: (arg: T, cb: (err: any, result: A) => void) => void): (arg: T) => Promise<A> {
|
||||
return function promisified (arg: T): Promise<A> {
|
||||
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
func.apply(null, [ arg, (err: any, res: A) => err ? reject(err) : resolve(res) ])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
export function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U) => Promise<A> {
|
||||
return function promisified (arg1: T, arg2: U): Promise<A> {
|
||||
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
func.apply(null, [ arg1, arg2, (err: any, res: A) => err ? reject(err) : resolve(res) ])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
export function promisify3<T, U, V, A> (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise<A> {
|
||||
return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
|
||||
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// high excluded
|
||||
export function randomInt (low: number, high: number) {
|
||||
return Math.floor(Math.random() * (high - low) + low)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
|
||||
export function removeFragmentedMP4Ext (path: string) {
|
||||
return path.replace(/-fragmented.mp4$/i, '')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
function wait (milliseconds: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
export {
|
||||
wait
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Video, VideoPlaylist } from '@peertube/peertube-models'
|
||||
import { secondsToTime } from './date.js'
|
||||
|
||||
function addQueryParams (url: string, params: { [ id: string ]: string }) {
|
||||
const objUrl = new URL(url)
|
||||
|
||||
for (const key of Object.keys(params)) {
|
||||
objUrl.searchParams.append(key, params[key])
|
||||
}
|
||||
|
||||
return objUrl.toString()
|
||||
}
|
||||
|
||||
function removeQueryParams (url: string) {
|
||||
const objUrl = new URL(url)
|
||||
|
||||
objUrl.searchParams.forEach((_v, k) => objUrl.searchParams.delete(k))
|
||||
|
||||
return objUrl.toString()
|
||||
}
|
||||
|
||||
function queryParamsToObject (entries: any) {
|
||||
const result: { [ id: string ]: string | number | boolean } = {}
|
||||
|
||||
for (const [ key, value ] of entries) {
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
|
||||
return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
|
||||
}
|
||||
|
||||
function buildPlaylistWatchPath (playlist: Pick<VideoPlaylist, 'shortUUID'>) {
|
||||
return '/w/p/' + playlist.shortUUID
|
||||
}
|
||||
|
||||
function buildVideoWatchPath (video: Pick<Video, 'shortUUID'>) {
|
||||
return '/w/' + video.shortUUID
|
||||
}
|
||||
|
||||
function buildVideoLink (video: Pick<Video, 'shortUUID'>, base?: string) {
|
||||
return (base ?? window.location.origin) + buildVideoWatchPath(video)
|
||||
}
|
||||
|
||||
function buildPlaylistEmbedPath (playlist: Pick<VideoPlaylist, 'uuid'>) {
|
||||
return '/video-playlists/embed/' + playlist.uuid
|
||||
}
|
||||
|
||||
function buildPlaylistEmbedLink (playlist: Pick<VideoPlaylist, 'uuid'>, base?: string) {
|
||||
return (base ?? window.location.origin) + buildPlaylistEmbedPath(playlist)
|
||||
}
|
||||
|
||||
function buildVideoEmbedPath (video: Pick<Video, 'uuid'>) {
|
||||
return '/videos/embed/' + video.uuid
|
||||
}
|
||||
|
||||
function buildVideoEmbedLink (video: Pick<Video, 'uuid'>, base?: string) {
|
||||
return (base ?? window.location.origin) + buildVideoEmbedPath(video)
|
||||
}
|
||||
|
||||
function decorateVideoLink (options: {
|
||||
url: string
|
||||
|
||||
startTime?: number
|
||||
stopTime?: number
|
||||
|
||||
subtitle?: string
|
||||
|
||||
loop?: boolean
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
|
||||
// Embed options
|
||||
title?: boolean
|
||||
warningTitle?: boolean
|
||||
|
||||
controls?: boolean
|
||||
controlBar?: boolean
|
||||
|
||||
peertubeLink?: boolean
|
||||
p2p?: boolean
|
||||
|
||||
api?: boolean
|
||||
}) {
|
||||
const { url } = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (options.startTime !== undefined && options.startTime !== null) {
|
||||
const startTimeInt = Math.floor(options.startTime)
|
||||
params.set('start', secondsToTime(startTimeInt))
|
||||
}
|
||||
|
||||
if (options.stopTime) {
|
||||
const stopTimeInt = Math.floor(options.stopTime)
|
||||
params.set('stop', secondsToTime(stopTimeInt))
|
||||
}
|
||||
|
||||
if (options.subtitle) params.set('subtitle', options.subtitle)
|
||||
|
||||
if (options.loop === true) params.set('loop', '1')
|
||||
if (options.autoplay === true) params.set('autoplay', '1')
|
||||
if (options.muted === true) params.set('muted', '1')
|
||||
if (options.title === false) params.set('title', '0')
|
||||
if (options.warningTitle === false) params.set('warningTitle', '0')
|
||||
|
||||
if (options.controls === false) params.set('controls', '0')
|
||||
if (options.controlBar === false) params.set('controlBar', '0')
|
||||
|
||||
if (options.peertubeLink === false) params.set('peertubeLink', '0')
|
||||
if (options.p2p !== undefined) params.set('p2p', options.p2p ? '1' : '0')
|
||||
|
||||
if (options.api !== undefined) params.set('api', options.api ? '1' : '0')
|
||||
|
||||
return buildUrl(url, params)
|
||||
}
|
||||
|
||||
function decoratePlaylistLink (options: {
|
||||
url: string
|
||||
|
||||
playlistPosition?: number
|
||||
}) {
|
||||
const { url } = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (options.playlistPosition) params.set('playlistPosition', '' + options.playlistPosition)
|
||||
|
||||
return buildUrl(url, params)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
addQueryParams,
|
||||
removeQueryParams,
|
||||
queryParamsToObject,
|
||||
|
||||
buildPlaylistLink,
|
||||
buildVideoLink,
|
||||
|
||||
buildVideoWatchPath,
|
||||
buildPlaylistWatchPath,
|
||||
|
||||
buildPlaylistEmbedPath,
|
||||
buildVideoEmbedPath,
|
||||
|
||||
buildPlaylistEmbedLink,
|
||||
buildVideoEmbedLink,
|
||||
|
||||
decorateVideoLink,
|
||||
decoratePlaylistLink
|
||||
}
|
||||
|
||||
function buildUrl (url: string, params: URLSearchParams) {
|
||||
let hasParams = false
|
||||
params.forEach(() => { hasParams = true })
|
||||
|
||||
if (hasParams) return url + '?' + params.toString()
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Thanks https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
|
||||
function compareSemVer (a: string, b: string) {
|
||||
if (a.startsWith(b + '-')) return -1
|
||||
if (b.startsWith(a + '-')) return 1
|
||||
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })
|
||||
}
|
||||
|
||||
export {
|
||||
compareSemVer
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export const LOCALE_FILES = [ 'player', 'server' ]
|
||||
|
||||
export const I18N_LOCALES = {
|
||||
// Always first to avoid issues when using express acceptLanguages function when no accept language header is set
|
||||
'en-US': 'English',
|
||||
|
||||
// Keep it alphabetically sorted
|
||||
'ar': 'العربية',
|
||||
'ca-ES': 'Català',
|
||||
'cs-CZ': 'Čeština',
|
||||
'de-DE': 'Deutsch',
|
||||
'el-GR': 'ελληνικά',
|
||||
'eo': 'Esperanto',
|
||||
'es-ES': 'Español',
|
||||
'eu-ES': 'Euskara',
|
||||
'fa-IR': 'فارسی',
|
||||
'fi-FI': 'Suomi',
|
||||
'fr-FR': 'Français',
|
||||
'gd': 'Gàidhlig',
|
||||
'gl-ES': 'Galego',
|
||||
'hr': 'Hrvatski',
|
||||
'hu-HU': 'Magyar',
|
||||
'is': 'Íslenska',
|
||||
'it-IT': 'Italiano',
|
||||
'ja-JP': '日本語',
|
||||
'kab': 'Taqbaylit',
|
||||
'nb-NO': 'Norsk bokmål',
|
||||
'nl-NL': 'Nederlands',
|
||||
'nn': 'Norsk nynorsk',
|
||||
'oc': 'Occitan',
|
||||
'pl-PL': 'Polski',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
'pt-PT': 'Português (Portugal)',
|
||||
'ru-RU': 'Pусский',
|
||||
'sq': 'Shqip',
|
||||
'sv-SE': 'Svenska',
|
||||
'th-TH': 'ไทย',
|
||||
'tok': 'Toki Pona',
|
||||
'tr-TR': 'Türkçe',
|
||||
'uk-UA': 'украї́нська мо́ва',
|
||||
'vi-VN': 'Tiếng Việt',
|
||||
'zh-Hans-CN': '简体中文(中国)',
|
||||
'zh-Hant-TW': '繁體中文(台灣)'
|
||||
}
|
||||
|
||||
// Keep it alphabetically sorted
|
||||
const I18N_LOCALE_ALIAS = {
|
||||
'ar-001': 'ar',
|
||||
'ca': 'ca-ES',
|
||||
'cs': 'cs-CZ',
|
||||
'de': 'de-DE',
|
||||
'el': 'el-GR',
|
||||
'en': 'en-US',
|
||||
'es': 'es-ES',
|
||||
'eu': 'eu-ES',
|
||||
'fa': 'fa-IR',
|
||||
'fi': 'fi-FI',
|
||||
'fr': 'fr-FR',
|
||||
'gl': 'gl-ES',
|
||||
'hu': 'hu-HU',
|
||||
'it': 'it-IT',
|
||||
'ja': 'ja-JP',
|
||||
'nb': 'nb-NO',
|
||||
'nl': 'nl-NL',
|
||||
'pl': 'pl-PL',
|
||||
'pt': 'pt-BR',
|
||||
'ru': 'ru-RU',
|
||||
'sv': 'sv-SE',
|
||||
'th': 'th-TH',
|
||||
'tr': 'tr-TR',
|
||||
'uk': 'uk-UA',
|
||||
'vi': 'vi-VN',
|
||||
'zh-CN': 'zh-Hans-CN',
|
||||
'zh-Hans': 'zh-Hans-CN',
|
||||
'zh-Hant': 'zh-Hant-TW',
|
||||
'zh-TW': 'zh-Hant-TW',
|
||||
'zh': 'zh-Hans-CN'
|
||||
}
|
||||
|
||||
export const POSSIBLE_LOCALES = (Object.keys(I18N_LOCALES) as string[]).concat(Object.keys(I18N_LOCALE_ALIAS))
|
||||
|
||||
export function getDefaultLocale () {
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
export function isDefaultLocale (locale: string) {
|
||||
return getCompleteLocale(locale) === getCompleteLocale(getDefaultLocale())
|
||||
}
|
||||
|
||||
export function peertubeTranslate (str: string, translations?: { [ id: string ]: string }) {
|
||||
if (!translations?.[str]) return str
|
||||
|
||||
return translations[str]
|
||||
}
|
||||
|
||||
const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l)
|
||||
export function is18nPath (path: string) {
|
||||
return possiblePaths.includes(path)
|
||||
}
|
||||
|
||||
export function is18nLocale (locale: string) {
|
||||
return POSSIBLE_LOCALES.includes(locale)
|
||||
}
|
||||
|
||||
export function getCompleteLocale (locale: string) {
|
||||
if (!locale) return locale
|
||||
|
||||
const found = (I18N_LOCALE_ALIAS as any)[locale] as string
|
||||
|
||||
return found || locale
|
||||
}
|
||||
|
||||
export function getShortLocale (locale: string) {
|
||||
if (locale.includes('-') === false) return locale
|
||||
|
||||
return locale.split('-')[0]
|
||||
}
|
||||
|
||||
export function buildFileLocale (locale: string) {
|
||||
return getCompleteLocale(locale)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './i18n.js'
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './abuse/index.js'
|
||||
export * from './common/index.js'
|
||||
export * from './i18n/index.js'
|
||||
export * from './plugins/index.js'
|
||||
export * from './renderer/index.js'
|
||||
export * from './users/index.js'
|
||||
export * from './videos/index.js'
|
||||
export * from './string/index.js'
|
||||
@@ -0,0 +1,60 @@
|
||||
import { HookType, HookType_Type, RegisteredExternalAuthConfig } from '@peertube/peertube-models'
|
||||
import { isCatchable, isPromise } from '../common/promises.js'
|
||||
|
||||
function getHookType (hookName: string) {
|
||||
if (hookName.startsWith('filter:')) return HookType.FILTER
|
||||
if (hookName.startsWith('action:')) return HookType.ACTION
|
||||
|
||||
return HookType.STATIC
|
||||
}
|
||||
|
||||
async function internalRunHook <T> (options: {
|
||||
handler: Function
|
||||
hookType: HookType_Type
|
||||
result: T
|
||||
params: any
|
||||
onError: (err: Error) => void
|
||||
}) {
|
||||
const { handler, hookType, result, params, onError } = options
|
||||
|
||||
try {
|
||||
if (hookType === HookType.FILTER) {
|
||||
const p = handler(result, params)
|
||||
|
||||
const newResult = isPromise(p)
|
||||
? await p
|
||||
: p
|
||||
|
||||
return newResult
|
||||
}
|
||||
|
||||
// Action/static hooks do not have result value
|
||||
const p = handler(params)
|
||||
|
||||
if (hookType === HookType.STATIC) {
|
||||
if (isPromise(p)) await p
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (hookType === HookType.ACTION) {
|
||||
if (isCatchable(p)) p.catch((err: any) => onError(err))
|
||||
|
||||
return undefined
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
|
||||
return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
|
||||
}
|
||||
|
||||
export {
|
||||
getHookType,
|
||||
internalRunHook,
|
||||
getExternalAuthHref
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './hooks.js'
|
||||
@@ -0,0 +1,77 @@
|
||||
export function getDefaultSanitizeOptions () {
|
||||
return {
|
||||
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
|
||||
allowedSchemes: [ 'http', 'https' ],
|
||||
allowedAttributes: {
|
||||
'a': [ 'href', 'class', 'target', 'rel' ],
|
||||
'*': [ 'data-*' ]
|
||||
},
|
||||
transformTags: {
|
||||
a: (tagName: string, attribs: any) => {
|
||||
let rel = 'noopener noreferrer'
|
||||
if (attribs.rel === 'me') rel += ' me'
|
||||
|
||||
return {
|
||||
tagName,
|
||||
attribs: Object.assign(attribs, {
|
||||
target: '_blank',
|
||||
rel
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTextOnlySanitizeOptions () {
|
||||
return {
|
||||
allowedTags: [] as string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) {
|
||||
const base = getDefaultSanitizeOptions()
|
||||
|
||||
return {
|
||||
allowedTags: [
|
||||
...base.allowedTags,
|
||||
...additionalAllowedTags,
|
||||
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img'
|
||||
],
|
||||
allowedSchemes: [
|
||||
...base.allowedSchemes,
|
||||
|
||||
'mailto'
|
||||
],
|
||||
allowedAttributes: {
|
||||
...base.allowedAttributes,
|
||||
|
||||
'img': [ 'src', 'alt' ],
|
||||
'*': [ 'data-*', 'style' ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thanks: https://stackoverflow.com/a/12034334
|
||||
export function escapeHTML (stringParam: string) {
|
||||
if (!stringParam) return ''
|
||||
|
||||
const entityMap: { [id: string ]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
'\'': ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
}
|
||||
|
||||
return String(stringParam).replace(/[&<>"'`=/]/g, s => entityMap[s])
|
||||
}
|
||||
|
||||
export function escapeAttribute (value: string) {
|
||||
if (!value) return ''
|
||||
|
||||
return String(value).replace(/"/g, '"')
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './markdown.js'
|
||||
export * from './html.js'
|
||||
@@ -0,0 +1,24 @@
|
||||
export const TEXT_RULES = [
|
||||
'linkify',
|
||||
'autolink',
|
||||
'emphasis',
|
||||
'link',
|
||||
'newline',
|
||||
'entity',
|
||||
'list'
|
||||
]
|
||||
|
||||
export const TEXT_WITH_HTML_RULES = TEXT_RULES.concat([
|
||||
'html_inline',
|
||||
'html_block'
|
||||
])
|
||||
|
||||
export const ENHANCED_RULES = TEXT_RULES.concat([ 'image' ])
|
||||
export const ENHANCED_WITH_HTML_RULES = TEXT_WITH_HTML_RULES.concat([ 'image' ])
|
||||
|
||||
export const COMPLETE_RULES = ENHANCED_WITH_HTML_RULES.concat([
|
||||
'block',
|
||||
'inline',
|
||||
'heading',
|
||||
'paragraph'
|
||||
])
|
||||
@@ -0,0 +1,35 @@
|
||||
import { timeToInt, timecodeRegexString } from '../common/date.js'
|
||||
|
||||
const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`)
|
||||
|
||||
export function parseChapters (text: string, maxTitleLength: number) {
|
||||
if (!text) return []
|
||||
|
||||
const lines = text.split(/\r?\n|\r|\n/g)
|
||||
let foundChapters = false
|
||||
|
||||
const chapters: { timecode: number, title: string }[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const matched = line.match(timecodeRegex)
|
||||
if (!matched) {
|
||||
// Stop chapters parsing
|
||||
if (foundChapters) break
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
foundChapters = true
|
||||
|
||||
const timecodeText = matched[1]
|
||||
const timecode = timeToInt(timecodeText)
|
||||
const title = line.replace(matched[0], '')
|
||||
|
||||
chapters.push({ timecode, title: title.slice(0, maxTitleLength) })
|
||||
}
|
||||
|
||||
// Only consider chapters if there are more than one
|
||||
if (chapters.length > 1) return chapters
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './chapters.js'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './user-role.js'
|
||||
@@ -0,0 +1,39 @@
|
||||
import { UserRight, UserRightType, UserRole, UserRoleType } from '@peertube/peertube-models'
|
||||
|
||||
export const USER_ROLE_LABELS: { [ id in UserRoleType ]: string } = {
|
||||
[UserRole.USER]: 'User',
|
||||
[UserRole.MODERATOR]: 'Moderator',
|
||||
[UserRole.ADMINISTRATOR]: 'Administrator'
|
||||
}
|
||||
|
||||
const userRoleRights: { [ id in UserRoleType ]: UserRightType[] } = {
|
||||
[UserRole.ADMINISTRATOR]: [
|
||||
UserRight.ALL
|
||||
],
|
||||
|
||||
[UserRole.MODERATOR]: [
|
||||
UserRight.MANAGE_VIDEO_BLACKLIST,
|
||||
UserRight.MANAGE_ABUSES,
|
||||
UserRight.MANAGE_ANY_VIDEO_CHANNEL,
|
||||
UserRight.REMOVE_ANY_VIDEO,
|
||||
UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
|
||||
UserRight.MANAGE_ANY_VIDEO_COMMENT,
|
||||
UserRight.UPDATE_ANY_VIDEO,
|
||||
UserRight.SEE_ALL_VIDEOS,
|
||||
UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
|
||||
UserRight.MANAGE_SERVERS_BLOCKLIST,
|
||||
UserRight.MANAGE_USERS,
|
||||
UserRight.SEE_ALL_COMMENTS,
|
||||
UserRight.MANAGE_REGISTRATIONS,
|
||||
UserRight.MANAGE_INSTANCE_WATCHED_WORDS,
|
||||
UserRight.MANAGE_INSTANCE_AUTO_TAGS
|
||||
],
|
||||
|
||||
[UserRole.USER]: []
|
||||
}
|
||||
|
||||
export function hasUserRight (userRole: UserRoleType, userRight: UserRightType) {
|
||||
const userRights = userRoleRights[userRole]
|
||||
|
||||
return userRights.includes(UserRight.ALL) || userRights.includes(userRight)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { VideoResolution, VideoResolutionType } from '@peertube/peertube-models'
|
||||
|
||||
type BitPerPixel = { [ id in VideoResolutionType ]: number }
|
||||
|
||||
// https://bitmovin.com/video-bitrate-streaming-hls-dash/
|
||||
|
||||
const minLimitBitPerPixel: BitPerPixel = {
|
||||
[VideoResolution.H_NOVIDEO]: 0,
|
||||
[VideoResolution.H_144P]: 0.02,
|
||||
[VideoResolution.H_240P]: 0.02,
|
||||
[VideoResolution.H_360P]: 0.02,
|
||||
[VideoResolution.H_480P]: 0.02,
|
||||
[VideoResolution.H_720P]: 0.02,
|
||||
[VideoResolution.H_1080P]: 0.02,
|
||||
[VideoResolution.H_1440P]: 0.02,
|
||||
[VideoResolution.H_4K]: 0.02
|
||||
}
|
||||
|
||||
const averageBitPerPixel: BitPerPixel = {
|
||||
[VideoResolution.H_NOVIDEO]: 0,
|
||||
[VideoResolution.H_144P]: 0.19,
|
||||
[VideoResolution.H_240P]: 0.17,
|
||||
[VideoResolution.H_360P]: 0.15,
|
||||
[VideoResolution.H_480P]: 0.12,
|
||||
[VideoResolution.H_720P]: 0.11,
|
||||
[VideoResolution.H_1080P]: 0.10,
|
||||
[VideoResolution.H_1440P]: 0.09,
|
||||
[VideoResolution.H_4K]: 0.08
|
||||
}
|
||||
|
||||
const maxBitPerPixel: BitPerPixel = {
|
||||
[VideoResolution.H_NOVIDEO]: 0,
|
||||
[VideoResolution.H_144P]: 0.32,
|
||||
[VideoResolution.H_240P]: 0.29,
|
||||
[VideoResolution.H_360P]: 0.26,
|
||||
[VideoResolution.H_480P]: 0.22,
|
||||
[VideoResolution.H_720P]: 0.19,
|
||||
[VideoResolution.H_1080P]: 0.17,
|
||||
[VideoResolution.H_1440P]: 0.16,
|
||||
[VideoResolution.H_4K]: 0.14
|
||||
}
|
||||
|
||||
function getAverageTheoreticalBitrate (options: {
|
||||
resolution: number
|
||||
ratio: number
|
||||
fps: number
|
||||
}) {
|
||||
const targetBitrate = calculateBitrate({ ...options, bitPerPixel: averageBitPerPixel })
|
||||
if (!targetBitrate) return 192 * 1000
|
||||
|
||||
return targetBitrate
|
||||
}
|
||||
|
||||
function getMaxTheoreticalBitrate (options: {
|
||||
resolution: number
|
||||
ratio: number
|
||||
fps: number
|
||||
}) {
|
||||
const targetBitrate = calculateBitrate({ ...options, bitPerPixel: maxBitPerPixel })
|
||||
if (!targetBitrate) return 256 * 1000
|
||||
|
||||
return targetBitrate
|
||||
}
|
||||
|
||||
function getMinTheoreticalBitrate (options: {
|
||||
resolution: number
|
||||
ratio: number
|
||||
fps: number
|
||||
}) {
|
||||
const minLimitBitrate = calculateBitrate({ ...options, bitPerPixel: minLimitBitPerPixel })
|
||||
if (!minLimitBitrate) return 10 * 1000
|
||||
|
||||
return minLimitBitrate
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getAverageTheoreticalBitrate,
|
||||
getMaxTheoreticalBitrate,
|
||||
getMinTheoreticalBitrate
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function calculateBitrate (options: {
|
||||
bitPerPixel: BitPerPixel
|
||||
resolution: number
|
||||
ratio: number
|
||||
fps: number
|
||||
}) {
|
||||
const { bitPerPixel, resolution, ratio, fps } = options
|
||||
|
||||
const resolutionsOrder = [
|
||||
VideoResolution.H_4K,
|
||||
VideoResolution.H_1440P,
|
||||
VideoResolution.H_1080P,
|
||||
VideoResolution.H_720P,
|
||||
VideoResolution.H_480P,
|
||||
VideoResolution.H_360P,
|
||||
VideoResolution.H_240P,
|
||||
VideoResolution.H_144P,
|
||||
VideoResolution.H_NOVIDEO
|
||||
]
|
||||
|
||||
const size1 = resolution
|
||||
const size2 = ratio < 1 && ratio > 0
|
||||
? resolution / ratio // Portrait mode
|
||||
: resolution * ratio
|
||||
|
||||
for (const toTestResolution of resolutionsOrder) {
|
||||
if (toTestResolution <= resolution) {
|
||||
return Math.floor(size1 * size2 * fps * bitPerPixel[toTestResolution])
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unknown resolution ' + resolution)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { VideoDetails, VideoPrivacy, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
|
||||
export function getAllPrivacies () {
|
||||
return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ]
|
||||
}
|
||||
|
||||
export function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) {
|
||||
const files = video.files
|
||||
|
||||
const hls = getHLS(video)
|
||||
if (hls) return files.concat(hls.files)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export function getHLS (video: Partial<Pick<VideoDetails, 'streamingPlaylists'>>) {
|
||||
return video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
|
||||
}
|
||||
|
||||
export function buildAspectRatio (options: { width: number, height: number }) {
|
||||
const { width, height } = options
|
||||
if (!width || !height) return null
|
||||
|
||||
return Math.round((width / height) * 10000) / 10000 // 4 decimals precision
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './bitrate.js'
|
||||
export * from './common.js'
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../models" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@peertube/peertube-ffmpeg",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"files": [ "dist" ],
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"peertube:tsx": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { pick, promisify0 } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
AvailableEncoders,
|
||||
EncoderOptionsBuilder,
|
||||
EncoderOptionsBuilderParams,
|
||||
EncoderProfile,
|
||||
SimpleLogger
|
||||
} from '@peertube/peertube-models'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
|
||||
export interface FFmpegCommandWrapperOptions {
|
||||
availableEncoders?: AvailableEncoders
|
||||
profile?: string
|
||||
|
||||
niceness: number
|
||||
tmpDirectory: string
|
||||
threads: number
|
||||
|
||||
logger: SimpleLogger
|
||||
lTags?: { tags: string[] }
|
||||
|
||||
updateJobProgress?: (progress?: number) => void
|
||||
onEnd?: () => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
export class FFmpegCommandWrapper {
|
||||
private static supportedEncoders: Map<string, boolean>
|
||||
|
||||
private readonly availableEncoders: AvailableEncoders
|
||||
private readonly profile: string
|
||||
|
||||
private readonly niceness: number
|
||||
private readonly tmpDirectory: string
|
||||
private readonly threads: number
|
||||
|
||||
private readonly logger: SimpleLogger
|
||||
private readonly lTags: { tags: string[] }
|
||||
|
||||
private readonly updateJobProgress: (progress?: number) => void
|
||||
private readonly onEnd?: () => void
|
||||
private readonly onError?: (err: Error) => void
|
||||
|
||||
private command: FfmpegCommand
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.availableEncoders = options.availableEncoders
|
||||
this.profile = options.profile
|
||||
this.niceness = options.niceness
|
||||
this.tmpDirectory = options.tmpDirectory
|
||||
this.threads = options.threads
|
||||
this.logger = options.logger
|
||||
this.lTags = options.lTags || { tags: [] }
|
||||
|
||||
this.updateJobProgress = options.updateJobProgress
|
||||
|
||||
this.onEnd = options.onEnd
|
||||
this.onError = options.onError
|
||||
}
|
||||
|
||||
getAvailableEncoders () {
|
||||
return this.availableEncoders
|
||||
}
|
||||
|
||||
getProfile () {
|
||||
return this.profile
|
||||
}
|
||||
|
||||
getCommand () {
|
||||
return this.command
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
debugLog (msg: string, meta: any = {}) {
|
||||
this.logger.debug(msg, { ...meta, ...this.lTags })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
resetCommand () {
|
||||
this.command = undefined
|
||||
}
|
||||
|
||||
buildCommand (input: string, inputFileMutexReleaser?: MutexInterface.Releaser) {
|
||||
if (this.command) throw new Error('Command is already built')
|
||||
|
||||
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
|
||||
this.command = ffmpeg(input, {
|
||||
niceness: this.niceness,
|
||||
cwd: this.tmpDirectory
|
||||
})
|
||||
|
||||
if (this.threads > 0) {
|
||||
// If we don't set any threads ffmpeg will chose automatically
|
||||
this.command.outputOption('-threads ' + this.threads)
|
||||
}
|
||||
|
||||
if (inputFileMutexReleaser) {
|
||||
this.command.on('start', () => {
|
||||
setTimeout(() => inputFileMutexReleaser(), 1000)
|
||||
})
|
||||
}
|
||||
|
||||
return this.command
|
||||
}
|
||||
|
||||
async runCommand (options: {
|
||||
silent?: boolean // false by default
|
||||
} = {}) {
|
||||
const { silent = false } = options
|
||||
|
||||
return new Promise<void>((res, rej) => {
|
||||
let shellCommand: string
|
||||
|
||||
this.command.on('start', cmdline => { shellCommand = cmdline })
|
||||
|
||||
this.command.on('error', (err, stdout, stderr) => {
|
||||
if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
|
||||
|
||||
if (this.onError) this.onError(err)
|
||||
|
||||
rej(err)
|
||||
})
|
||||
|
||||
this.command.on('end', (stdout, stderr) => {
|
||||
this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
|
||||
|
||||
if (this.onEnd) this.onEnd()
|
||||
|
||||
res()
|
||||
})
|
||||
|
||||
if (this.updateJobProgress) {
|
||||
this.command.on('progress', progress => {
|
||||
if (!progress.percent) return
|
||||
|
||||
// Sometimes ffmpeg returns an invalid progress
|
||||
let percent = Math.round(progress.percent)
|
||||
if (percent < 0) percent = 0
|
||||
if (percent > 100) percent = 100
|
||||
|
||||
this.updateJobProgress(percent)
|
||||
})
|
||||
}
|
||||
|
||||
this.command.run()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static resetSupportedEncoders () {
|
||||
FFmpegCommandWrapper.supportedEncoders = undefined
|
||||
}
|
||||
|
||||
// Run encoder builder depending on available encoders
|
||||
// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
|
||||
// If the default one does not exist, check the next encoder
|
||||
async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
|
||||
streamType: 'video' | 'audio'
|
||||
input: string
|
||||
|
||||
videoType: 'vod' | 'live'
|
||||
}) {
|
||||
if (!this.availableEncoders) {
|
||||
throw new Error('There is no available encoders')
|
||||
}
|
||||
|
||||
const { streamType, videoType } = options
|
||||
|
||||
const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
|
||||
const encoders = this.availableEncoders.available[videoType]
|
||||
|
||||
for (const encoder of encodersToTry) {
|
||||
if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
|
||||
this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!encoders[encoder]) {
|
||||
this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
|
||||
continue
|
||||
}
|
||||
|
||||
// An object containing available profiles for this encoder
|
||||
const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
|
||||
let builder = builderProfiles[this.profile]
|
||||
|
||||
if (!builder) {
|
||||
this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
|
||||
builder = builderProfiles.default
|
||||
|
||||
if (!builder) {
|
||||
this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const result = await builder(
|
||||
pick(options, [
|
||||
'input',
|
||||
'canCopyAudio',
|
||||
'canCopyVideo',
|
||||
'resolution',
|
||||
'inputBitrate',
|
||||
'inputProbe',
|
||||
'fps',
|
||||
'inputRatio',
|
||||
'streamNum'
|
||||
])
|
||||
)
|
||||
|
||||
return {
|
||||
result,
|
||||
|
||||
// If we don't have output options, then copy the input stream
|
||||
encoder: result.copy === true
|
||||
? 'copy'
|
||||
: encoder
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Detect supported encoders by ffmpeg
|
||||
private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
|
||||
if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
|
||||
return FFmpegCommandWrapper.supportedEncoders
|
||||
}
|
||||
|
||||
const getAvailableEncodersPromise = promisify0(ffmpeg.getAvailableEncoders)
|
||||
const availableFFmpegEncoders = await getAvailableEncodersPromise()
|
||||
|
||||
const searchEncoders = new Set<string>()
|
||||
for (const type of [ 'live', 'vod' ]) {
|
||||
for (const streamType of [ 'audio', 'video' ]) {
|
||||
for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
|
||||
searchEncoders.add(encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const supportedEncoders = new Map<string, boolean>()
|
||||
|
||||
for (const searchEncoder of searchEncoders) {
|
||||
supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
|
||||
}
|
||||
|
||||
this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
|
||||
|
||||
FFmpegCommandWrapper.supportedEncoders = supportedEncoders
|
||||
return supportedEncoders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
buildStreamSuffix,
|
||||
getAudioStream,
|
||||
getMaxAudioBitrate,
|
||||
getVideoStream,
|
||||
getVideoStreamBitrate,
|
||||
getVideoStreamDimensionsInfo,
|
||||
getVideoStreamFPS
|
||||
} from '@peertube/peertube-ffmpeg'
|
||||
import { EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '@peertube/peertube-models'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
|
||||
const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
||||
const { fps, inputRatio, inputBitrate, resolution } = options
|
||||
|
||||
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
||||
|
||||
return {
|
||||
outputOptions: [
|
||||
...getCommonOutputOptions(targetBitrate),
|
||||
|
||||
`-r ${fps}`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
||||
const { streamNum, fps, inputBitrate, inputRatio, resolution } = options
|
||||
|
||||
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
||||
|
||||
return {
|
||||
outputOptions: [
|
||||
...getCommonOutputOptions(targetBitrate, streamNum),
|
||||
|
||||
`${buildStreamSuffix('-r:v', streamNum)} ${fps}`,
|
||||
`${buildStreamSuffix('-b:v', streamNum)} ${targetBitrate}`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio, inputProbe }) => {
|
||||
if (canCopyAudio && await canDoQuickAudioTranscode(input, inputProbe)) {
|
||||
return { copy: true, outputOptions: [ ] }
|
||||
}
|
||||
|
||||
const parsedAudio = await getAudioStream(input, inputProbe)
|
||||
|
||||
// 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
|
||||
|
||||
const audioCodecName = parsedAudio.audioStream['codec_name']
|
||||
|
||||
const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate)
|
||||
|
||||
// Force stereo as it causes some issues with HLS playback in Chrome
|
||||
const base = [ '-channel_layout', 'stereo' ]
|
||||
|
||||
if (bitrate !== -1) {
|
||||
return { outputOptions: base.concat([ buildStreamSuffix('-b:a', streamNum), bitrate + 'k' ]) }
|
||||
}
|
||||
|
||||
return { outputOptions: base }
|
||||
}
|
||||
|
||||
const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum }) => {
|
||||
return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
|
||||
}
|
||||
|
||||
export function getDefaultAvailableEncoders () {
|
||||
return {
|
||||
vod: {
|
||||
libx264: {
|
||||
default: defaultX264VODOptionsBuilder
|
||||
},
|
||||
aac: {
|
||||
default: defaultAACOptionsBuilder
|
||||
},
|
||||
libfdk_aac: {
|
||||
default: defaultLibFDKAACVODOptionsBuilder
|
||||
}
|
||||
},
|
||||
live: {
|
||||
libx264: {
|
||||
default: defaultX264LiveOptionsBuilder
|
||||
},
|
||||
aac: {
|
||||
default: defaultAACOptionsBuilder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultEncodersToTry () {
|
||||
return {
|
||||
vod: {
|
||||
video: [ 'libx264' ],
|
||||
audio: [ 'libfdk_aac', 'aac' ]
|
||||
},
|
||||
|
||||
live: {
|
||||
video: [ 'libx264' ],
|
||||
audio: [ 'libfdk_aac', 'aac' ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||
const parsedAudio = await getAudioStream(path, probe)
|
||||
|
||||
if (!parsedAudio.audioStream) return true
|
||||
|
||||
if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
|
||||
|
||||
const audioBitrate = parsedAudio.bitrate
|
||||
if (!audioBitrate) return false
|
||||
|
||||
const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
|
||||
if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
|
||||
|
||||
const channelLayout = parsedAudio.audioStream['channel_layout']
|
||||
// Causes playback issues with Chrome
|
||||
if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||
const videoStream = await getVideoStream(path, probe)
|
||||
const fps = await getVideoStreamFPS(path, probe)
|
||||
const bitRate = await getVideoStreamBitrate(path, probe)
|
||||
const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
|
||||
|
||||
// If ffprobe did not manage to guess the bitrate
|
||||
if (!bitRate) return false
|
||||
|
||||
// check video params
|
||||
if (!videoStream) return false
|
||||
if (videoStream['codec_name'] !== 'h264') return false
|
||||
if (videoStream['pix_fmt'] !== 'yuv420p') return false
|
||||
if (fps < 2 || fps > 65) return false
|
||||
if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTargetBitrate (options: {
|
||||
inputBitrate: number
|
||||
resolution: number
|
||||
ratio: number
|
||||
fps: number
|
||||
}) {
|
||||
const { inputBitrate, resolution, ratio, fps } = options
|
||||
|
||||
const capped = capBitrate(inputBitrate, getAverageTheoreticalBitrate({ resolution, fps, ratio }))
|
||||
const limit = getMinTheoreticalBitrate({ resolution, fps, ratio })
|
||||
|
||||
return Math.max(limit, capped)
|
||||
}
|
||||
|
||||
function capBitrate (inputBitrate: number, targetBitrate: number) {
|
||||
if (!inputBitrate) return targetBitrate
|
||||
|
||||
// Add 30% margin to input bitrate
|
||||
const inputBitrateWithMargin = inputBitrate + (inputBitrate * 0.3)
|
||||
|
||||
return Math.min(targetBitrate, inputBitrateWithMargin)
|
||||
}
|
||||
|
||||
function getCommonOutputOptions (targetBitrate: number, streamNum?: number) {
|
||||
return [
|
||||
`-preset veryfast`,
|
||||
`${buildStreamSuffix('-maxrate:v', streamNum)} ${targetBitrate}`,
|
||||
`${buildStreamSuffix('-bufsize:v', streamNum)} ${targetBitrate * 2}`,
|
||||
|
||||
// NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
|
||||
`-b_strategy 1`,
|
||||
// NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
|
||||
`-bf 16`
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { FilterSpecification } from 'fluent-ffmpeg'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { presetVOD } from './shared/presets.js'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
|
||||
|
||||
export class FFmpegEdition {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async cutVideo (options: {
|
||||
inputPath: string
|
||||
outputPath: string
|
||||
start?: number
|
||||
end?: number
|
||||
}) {
|
||||
const { inputPath, outputPath } = options
|
||||
|
||||
const mainProbe = await ffprobePromise(inputPath)
|
||||
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputPath)
|
||||
.output(outputPath)
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: inputPath,
|
||||
resolution,
|
||||
fps,
|
||||
canCopyAudio: false,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
if (options.start) {
|
||||
command.outputOption('-ss ' + options.start)
|
||||
}
|
||||
|
||||
if (options.end) {
|
||||
command.outputOption('-to ' + options.end)
|
||||
}
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
}
|
||||
|
||||
async addWatermark (options: {
|
||||
inputPath: string
|
||||
watermarkPath: string
|
||||
outputPath: string
|
||||
|
||||
videoFilters: {
|
||||
watermarkSizeRatio: number
|
||||
horitonzalMarginRatio: number
|
||||
verticalMarginRatio: number
|
||||
}
|
||||
}) {
|
||||
const { watermarkPath, inputPath, outputPath, videoFilters } = options
|
||||
|
||||
const videoProbe = await ffprobePromise(inputPath)
|
||||
const fps = await getVideoStreamFPS(inputPath, videoProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputPath)
|
||||
.output(outputPath)
|
||||
|
||||
command.input(watermarkPath)
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: inputPath,
|
||||
resolution,
|
||||
fps,
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
// Scale watermark
|
||||
{
|
||||
inputs: [ '[1]', '[0]' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'oh*mdar',
|
||||
h: `ih*${videoFilters.watermarkSizeRatio}`
|
||||
},
|
||||
outputs: [ '[watermark]', '[video]' ]
|
||||
},
|
||||
|
||||
{
|
||||
inputs: [ '[video]', '[watermark]' ],
|
||||
filter: 'overlay',
|
||||
options: {
|
||||
x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`,
|
||||
y: `main_h * ${videoFilters.verticalMarginRatio}`
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
}
|
||||
|
||||
async addIntroOutro (options: {
|
||||
inputPath: string
|
||||
introOutroPath: string
|
||||
outputPath: string
|
||||
type: 'intro' | 'outro'
|
||||
}) {
|
||||
const { introOutroPath, inputPath, outputPath, type } = options
|
||||
|
||||
const mainProbe = await ffprobePromise(inputPath)
|
||||
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
||||
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
|
||||
|
||||
const introOutroProbe = await ffprobePromise(introOutroPath)
|
||||
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputPath)
|
||||
.output(outputPath)
|
||||
|
||||
command.input(introOutroPath)
|
||||
|
||||
if (!introOutroHasAudio && mainHasAudio) {
|
||||
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
|
||||
|
||||
command.input('anullsrc')
|
||||
command.withInputFormat('lavfi')
|
||||
command.withInputOption('-t ' + duration)
|
||||
}
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: inputPath,
|
||||
resolution,
|
||||
fps,
|
||||
canCopyAudio: false,
|
||||
canCopyVideo: false
|
||||
})
|
||||
|
||||
// Add black background to correctly scale intro/outro with padding
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
{
|
||||
inputs: [ '1', '0' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'iw',
|
||||
h: `ih`
|
||||
},
|
||||
outputs: [ 'intro-outro', 'main' ]
|
||||
},
|
||||
{
|
||||
inputs: [ 'intro-outro', 'main' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'iw',
|
||||
h: `ih`
|
||||
},
|
||||
outputs: [ 'to-scale', 'main' ]
|
||||
},
|
||||
{
|
||||
inputs: 'to-scale',
|
||||
filter: 'drawbox',
|
||||
options: {
|
||||
t: 'fill'
|
||||
},
|
||||
outputs: [ 'to-scale-bg' ]
|
||||
},
|
||||
{
|
||||
inputs: [ '1', 'to-scale-bg' ],
|
||||
filter: 'scale2ref',
|
||||
options: {
|
||||
w: 'iw',
|
||||
h: 'ih',
|
||||
force_original_aspect_ratio: 'decrease',
|
||||
flags: 'spline'
|
||||
},
|
||||
outputs: [ 'to-scale', 'to-scale-bg' ]
|
||||
},
|
||||
{
|
||||
inputs: [ 'to-scale-bg', 'to-scale' ],
|
||||
filter: 'overlay',
|
||||
options: {
|
||||
x: '(main_w - overlay_w)/2',
|
||||
y: '(main_h - overlay_h)/2'
|
||||
},
|
||||
outputs: 'intro-outro-resized'
|
||||
}
|
||||
]
|
||||
|
||||
const concatFilter = {
|
||||
inputs: [],
|
||||
filter: 'concat',
|
||||
options: {
|
||||
n: 2,
|
||||
v: 1,
|
||||
unsafe: 1
|
||||
},
|
||||
outputs: [ 'v' ]
|
||||
}
|
||||
|
||||
const introOutroFilterInputs = [ 'intro-outro-resized' ]
|
||||
const mainFilterInputs = [ 'main' ]
|
||||
|
||||
if (mainHasAudio) {
|
||||
mainFilterInputs.push('0:a')
|
||||
|
||||
if (introOutroHasAudio) {
|
||||
introOutroFilterInputs.push('1:a')
|
||||
} else {
|
||||
// Silent input
|
||||
introOutroFilterInputs.push('2:a')
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'intro') {
|
||||
concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
|
||||
} else {
|
||||
concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
|
||||
}
|
||||
|
||||
if (mainHasAudio) {
|
||||
concatFilter.options['a'] = 1
|
||||
concatFilter.outputs.push('a')
|
||||
|
||||
command.outputOption('-map [a]')
|
||||
}
|
||||
|
||||
command.outputOption('-map [v]')
|
||||
|
||||
complexFilter.push(concatFilter)
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { getVideoStreamDuration } from './ffprobe.js'
|
||||
|
||||
export class FFmpegImage {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
convertWebPToJPG (options: {
|
||||
path: string
|
||||
destination: string
|
||||
}): Promise<void> {
|
||||
const { path, destination } = options
|
||||
|
||||
this.commandWrapper.buildCommand(path)
|
||||
.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand({ silent: true })
|
||||
}
|
||||
|
||||
processGIF (options: {
|
||||
path: string
|
||||
destination: string
|
||||
newSize: { width: number, height: number }
|
||||
}): Promise<void> {
|
||||
const { path, destination, newSize } = options
|
||||
|
||||
this.commandWrapper.buildCommand(path)
|
||||
.fps(20)
|
||||
.size(`${newSize.width}x${newSize.height}`)
|
||||
.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async generateThumbnailFromVideo (options: {
|
||||
fromPath: string
|
||||
output: string
|
||||
framesToAnalyze: number
|
||||
scale?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
ffprobe?: FfprobeData
|
||||
}) {
|
||||
const { fromPath, ffprobe } = options
|
||||
|
||||
let duration = await getVideoStreamDuration(fromPath, ffprobe)
|
||||
if (isNaN(duration)) duration = 0
|
||||
|
||||
this.buildGenerateThumbnailFromVideo(options)
|
||||
.seekInput(duration / 2)
|
||||
|
||||
try {
|
||||
return await this.commandWrapper.runCommand()
|
||||
} catch (err) {
|
||||
this.commandWrapper.debugLog('Cannot generate thumbnail from video using seek input, fallback to no seek', { err })
|
||||
|
||||
this.commandWrapper.resetCommand()
|
||||
|
||||
this.buildGenerateThumbnailFromVideo(options)
|
||||
|
||||
return this.commandWrapper.runCommand()
|
||||
}
|
||||
}
|
||||
|
||||
private buildGenerateThumbnailFromVideo (options: {
|
||||
fromPath: string
|
||||
output: string
|
||||
framesToAnalyze: number
|
||||
scale?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const { fromPath, output, framesToAnalyze, scale } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(fromPath)
|
||||
.videoFilter('thumbnail=' + framesToAnalyze)
|
||||
.outputOption('-frames:v 1')
|
||||
.outputOption('-q:v 5')
|
||||
.outputOption('-abort_on empty_output')
|
||||
.output(output)
|
||||
|
||||
if (scale) {
|
||||
command.videoFilter(`scale=${scale.width}x${scale.height}:force_original_aspect_ratio=decrease`)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async generateStoryboardFromVideo (options: {
|
||||
path: string
|
||||
destination: string
|
||||
|
||||
// Will be released after the ffmpeg started
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
sprites: {
|
||||
size: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
count: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
duration: number
|
||||
}
|
||||
}) {
|
||||
const { path, destination, sprites } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(path)
|
||||
|
||||
const filter = [
|
||||
// Fix "t" variable with some videos
|
||||
`setpts='N/FRAME_RATE/TB'`,
|
||||
// First frame or the time difference between the last and the current frame is enough for our sprite interval
|
||||
`select='isnan(prev_selected_t)+gte(t-prev_selected_t,${options.sprites.duration})'`,
|
||||
`scale=${sprites.size.width}:${sprites.size.height}`,
|
||||
`tile=layout=${sprites.count.width}x${sprites.count.height}`
|
||||
].join(',')
|
||||
|
||||
command.outputOption('-filter_complex', filter)
|
||||
command.outputOption('-frames:v', '1')
|
||||
command.outputOption('-q:v', '2')
|
||||
command.output(destination)
|
||||
|
||||
return this.commandWrapper.runCommand()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
|
||||
import { join } from 'path'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js'
|
||||
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
|
||||
|
||||
export class FFmpegLive {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async getLiveTranscodingCommand (options: {
|
||||
inputUrl: string
|
||||
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
|
||||
toTranscode: {
|
||||
resolution: number
|
||||
fps: number
|
||||
}[]
|
||||
|
||||
// Input information
|
||||
bitrate: number
|
||||
ratio: number
|
||||
hasAudio: boolean
|
||||
probe: FfprobeData
|
||||
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
}) {
|
||||
const {
|
||||
inputUrl,
|
||||
outPath,
|
||||
toTranscode,
|
||||
bitrate,
|
||||
masterPlaylistName,
|
||||
ratio,
|
||||
hasAudio,
|
||||
probe
|
||||
} = options
|
||||
const command = this.commandWrapper.buildCommand(inputUrl)
|
||||
|
||||
const varStreamMap: string[] = []
|
||||
|
||||
const complexFilter: FilterSpecification[] = [
|
||||
{
|
||||
inputs: '[v:0]',
|
||||
filter: 'split',
|
||||
options: toTranscode.length,
|
||||
outputs: toTranscode.map(t => `vtemp${t.resolution}`)
|
||||
}
|
||||
]
|
||||
|
||||
command.outputOption('-sc_threshold 0')
|
||||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
for (let i = 0; i < toTranscode.length; i++) {
|
||||
const streamMap: string[] = []
|
||||
const { resolution, fps } = toTranscode[i]
|
||||
|
||||
const baseEncoderBuilderParams = {
|
||||
input: inputUrl,
|
||||
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: true,
|
||||
|
||||
inputBitrate: bitrate,
|
||||
inputRatio: ratio,
|
||||
inputProbe: probe,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
|
||||
streamNum: i,
|
||||
videoType: 'live' as 'live'
|
||||
}
|
||||
|
||||
{
|
||||
const streamType: StreamType = 'video'
|
||||
|
||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||
if (!builderResult) {
|
||||
throw new Error('No available live video encoder found')
|
||||
}
|
||||
|
||||
command.outputOption(`-map [vout${resolution}]`)
|
||||
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
|
||||
|
||||
this.commandWrapper.debugLog(
|
||||
`Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, fps, toTranscode }
|
||||
)
|
||||
|
||||
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
|
||||
complexFilter.push({
|
||||
inputs: `vtemp${resolution}`,
|
||||
filter: getScaleFilter(builderResult.result),
|
||||
options: `w=-2:h=${resolution}`,
|
||||
outputs: `vout${resolution}`
|
||||
})
|
||||
|
||||
streamMap.push(`v:${i}`)
|
||||
}
|
||||
|
||||
if (hasAudio) {
|
||||
const streamType: StreamType = 'audio'
|
||||
|
||||
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||
if (!builderResult) {
|
||||
throw new Error('No available live audio encoder found')
|
||||
}
|
||||
|
||||
command.outputOption('-map a:0')
|
||||
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
|
||||
|
||||
this.commandWrapper.debugLog(
|
||||
`Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, fps, resolution }
|
||||
)
|
||||
|
||||
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
|
||||
streamMap.push(`a:${i}`)
|
||||
}
|
||||
|
||||
varStreamMap.push(streamMap.join(','))
|
||||
}
|
||||
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
||||
|
||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
getLiveMuxingCommand (options: {
|
||||
inputUrl: string
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
}) {
|
||||
const { inputUrl, outPath, masterPlaylistName } = options
|
||||
|
||||
const command = this.commandWrapper.buildCommand(inputUrl)
|
||||
|
||||
command.outputOption('-c:v copy')
|
||||
command.outputOption('-c:a copy')
|
||||
command.outputOption('-map 0:a?')
|
||||
command.outputOption('-map 0:v?')
|
||||
|
||||
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
private addDefaultLiveHLSParams (options: {
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
segmentListSize: number
|
||||
segmentDuration: number
|
||||
}) {
|
||||
const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options
|
||||
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
command.outputOption('-hls_time ' + segmentDuration)
|
||||
command.outputOption('-hls_list_size ' + segmentListSize)
|
||||
command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time')
|
||||
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
|
||||
command.outputOption('-master_pl_name ' + masterPlaylistName)
|
||||
command.outputOption(`-f hls`)
|
||||
|
||||
command.output(join(outPath, '%v.m3u8'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { EncoderOptions } from '@peertube/peertube-models'
|
||||
|
||||
export type StreamType = 'audio' | 'video'
|
||||
|
||||
export function buildStreamSuffix (base: string, streamNum?: number) {
|
||||
if (streamNum !== undefined) {
|
||||
return `${base}:${streamNum}`
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export function getScaleFilter (options: EncoderOptions): string {
|
||||
if (options.scaleFilter) return options.scaleFilter.name
|
||||
|
||||
return 'scale'
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { exec } from 'child_process'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
|
||||
/**
|
||||
* @returns FFmpeg version string. Usually a semver string, but may vary when depending on installation method.
|
||||
*/
|
||||
export function getFFmpegVersion () {
|
||||
return new Promise<string>((res, rej) => {
|
||||
(ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
|
||||
if (err) return rej(err)
|
||||
if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
|
||||
|
||||
return exec(`${ffmpegPath} -version`, (err, stdout) => {
|
||||
if (err) return rej(err)
|
||||
|
||||
const parsed = stdout.match(/(?<=ffmpeg version )[a-zA-Z\d.-]+/)
|
||||
if (!parsed) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
|
||||
|
||||
res(parsed[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { dirname } from 'path'
|
||||
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
|
||||
import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js'
|
||||
|
||||
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
|
||||
|
||||
export interface BaseTranscodeVODOptions {
|
||||
type: TranscodeVODOptionsType
|
||||
|
||||
inputPath: string
|
||||
outputPath: string
|
||||
|
||||
// Will be released after the ffmpeg started
|
||||
// To prevent a bug where the input file does not exist anymore when running ffmpeg
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'hls'
|
||||
|
||||
copyCodecs: boolean
|
||||
|
||||
hlsPlaylist: {
|
||||
videoFilename: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'hls-from-ts'
|
||||
|
||||
isAAC: boolean
|
||||
|
||||
hlsPlaylist: {
|
||||
videoFilename: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'quick-transcode'
|
||||
}
|
||||
|
||||
export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'video'
|
||||
}
|
||||
|
||||
export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
|
||||
type: 'merge-audio'
|
||||
audioPath: string
|
||||
}
|
||||
|
||||
export type TranscodeVODOptions =
|
||||
HLSTranscodeOptions
|
||||
| HLSFromTSTranscodeOptions
|
||||
| VideoTranscodeOptions
|
||||
| MergeAudioTranscodeOptions
|
||||
| QuickTranscodeOptions
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class FFmpegVOD {
|
||||
private readonly commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
private ended = false
|
||||
|
||||
constructor (options: FFmpegCommandWrapperOptions) {
|
||||
this.commandWrapper = new FFmpegCommandWrapper(options)
|
||||
}
|
||||
|
||||
async transcode (options: TranscodeVODOptions) {
|
||||
const builders: {
|
||||
[ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
|
||||
} = {
|
||||
'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
|
||||
'hls': this.buildHLSVODCommand.bind(this),
|
||||
'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
|
||||
'merge-audio': this.buildAudioMergeCommand.bind(this),
|
||||
'video': this.buildWebVideoCommand.bind(this)
|
||||
}
|
||||
|
||||
this.commandWrapper.debugLog('Will run transcode.', { options })
|
||||
|
||||
this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser)
|
||||
.output(options.outputPath)
|
||||
|
||||
await builders[options.type](options)
|
||||
|
||||
await this.commandWrapper.runCommand()
|
||||
|
||||
await this.fixHLSPlaylistIfNeeded(options)
|
||||
|
||||
this.ended = true
|
||||
}
|
||||
|
||||
isEnded () {
|
||||
return this.ended
|
||||
}
|
||||
|
||||
private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) {
|
||||
const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = options
|
||||
|
||||
if (resolution === VideoResolution.H_NOVIDEO) {
|
||||
presetOnlyAudio(this.commandWrapper)
|
||||
return
|
||||
}
|
||||
|
||||
let scaleFilterValue: string
|
||||
|
||||
if (resolution !== undefined) {
|
||||
const probe = await ffprobePromise(inputPath)
|
||||
const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
|
||||
|
||||
scaleFilterValue = videoStreamInfo?.isPortraitMode === true
|
||||
? `w=${resolution}:h=-2`
|
||||
: `w=-2:h=${resolution}`
|
||||
}
|
||||
|
||||
await presetVOD({
|
||||
commandWrapper: this.commandWrapper,
|
||||
|
||||
resolution,
|
||||
input: inputPath,
|
||||
canCopyAudio,
|
||||
canCopyVideo,
|
||||
fps,
|
||||
scaleFilterValue
|
||||
})
|
||||
}
|
||||
|
||||
private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
presetCopy(this.commandWrapper)
|
||||
|
||||
command.outputOption('-map_metadata -1') // strip all metadata
|
||||
.outputOption('-movflags faststart')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio transcoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
command.loop(undefined)
|
||||
|
||||
await presetVOD({
|
||||
...pick(options, [ 'resolution' ]),
|
||||
|
||||
commandWrapper: this.commandWrapper,
|
||||
input: options.audioPath,
|
||||
canCopyAudio: true,
|
||||
canCopyVideo: true,
|
||||
fps: options.fps,
|
||||
scaleFilterValue: this.getMergeAudioScaleFilterValue()
|
||||
})
|
||||
|
||||
command.outputOption('-preset:v veryfast')
|
||||
|
||||
command.input(options.audioPath)
|
||||
.outputOption('-tune stillimage')
|
||||
.outputOption('-shortest')
|
||||
}
|
||||
|
||||
// Avoid "height not divisible by 2" error
|
||||
private getMergeAudioScaleFilterValue () {
|
||||
return 'trunc(iw/2)*2:trunc(ih/2)*2'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS transcoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async buildHLSVODCommand (options: HLSTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
const videoPath = this.getHLSVideoPath(options)
|
||||
|
||||
if (options.copyCodecs) {
|
||||
presetCopy(this.commandWrapper)
|
||||
} else if (options.resolution === VideoResolution.H_NOVIDEO) {
|
||||
presetOnlyAudio(this.commandWrapper)
|
||||
} else {
|
||||
// If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync
|
||||
// See for example https://github.com/Chocobozzz/PeerTube/issues/6438
|
||||
await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false })
|
||||
}
|
||||
|
||||
this.addCommonHLSVODCommandOptions(command, videoPath)
|
||||
}
|
||||
|
||||
private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
|
||||
const command = this.commandWrapper.getCommand()
|
||||
|
||||
const videoPath = this.getHLSVideoPath(options)
|
||||
|
||||
command.outputOption('-c copy')
|
||||
|
||||
if (options.isAAC) {
|
||||
// Required for example when copying an AAC stream from an MPEG-TS
|
||||
// Since it's a bitstream filter, we don't need to reencode the audio
|
||||
command.outputOption('-bsf:a aac_adtstoasc')
|
||||
}
|
||||
|
||||
this.addCommonHLSVODCommandOptions(command, videoPath)
|
||||
}
|
||||
|
||||
private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
|
||||
return command.outputOption('-hls_time 4')
|
||||
.outputOption('-hls_list_size 0')
|
||||
.outputOption('-hls_playlist_type vod')
|
||||
.outputOption('-hls_segment_filename ' + outputPath)
|
||||
.outputOption('-hls_segment_type fmp4')
|
||||
.outputOption('-f hls')
|
||||
.outputOption('-hls_flags single_file')
|
||||
}
|
||||
|
||||
private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
|
||||
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
|
||||
|
||||
const fileContent = await readFile(options.outputPath)
|
||||
|
||||
const videoFileName = options.hlsPlaylist.videoFilename
|
||||
const videoFilePath = this.getHLSVideoPath(options)
|
||||
|
||||
// Fix wrong mapping with some ffmpeg versions
|
||||
const newContent = fileContent.toString()
|
||||
.replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
|
||||
|
||||
await writeFile(options.outputPath, newContent)
|
||||
}
|
||||
|
||||
private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
|
||||
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'
|
||||
import { buildAspectRatio, forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
|
||||
/**
|
||||
*
|
||||
* Helpers to run ffprobe and extract data from the JSON output
|
||||
*
|
||||
*/
|
||||
|
||||
function ffprobePromise (path: string) {
|
||||
return new Promise<FfprobeData>((res, rej) => {
|
||||
ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => {
|
||||
if (err) return rej(err)
|
||||
|
||||
return res(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const imageCodecs = new Set([
|
||||
'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2',
|
||||
'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff',
|
||||
'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd'
|
||||
])
|
||||
|
||||
async function isAudioFile (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return true
|
||||
|
||||
if (imageCodecs.has(videoStream.codec_name)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
|
||||
const { audioStream } = await getAudioStream(path, existingProbe)
|
||||
|
||||
return !!audioStream
|
||||
}
|
||||
|
||||
async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
|
||||
// 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
|
||||
const data = existingProbe || await ffprobePromise(videoPath)
|
||||
|
||||
if (Array.isArray(data.streams)) {
|
||||
const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
|
||||
|
||||
if (audioStream) {
|
||||
return {
|
||||
absolutePath: data.format.filename,
|
||||
audioStream,
|
||||
bitrate: forceNumber(audioStream['bit_rate'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { absolutePath: data.format.filename }
|
||||
}
|
||||
|
||||
function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) {
|
||||
const maxKBitrate = 384
|
||||
const kToBits = (kbits: number) => kbits * 1000
|
||||
|
||||
// If we did not manage to get the bitrate, use an average value
|
||||
if (!bitrate) return 256
|
||||
|
||||
if (type === 'aac') {
|
||||
switch (true) {
|
||||
case bitrate > kToBits(maxKBitrate):
|
||||
return maxKBitrate
|
||||
|
||||
default:
|
||||
return -1 // we interpret it as a signal to copy the audio stream as is
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
|
||||
That's why, when using aac, we can go to lower kbit/sec. The equivalences
|
||||
made here are not made to be accurate, especially with good mp3 encoders.
|
||||
*/
|
||||
switch (true) {
|
||||
case bitrate <= kToBits(192):
|
||||
return 128
|
||||
|
||||
case bitrate <= kToBits(384):
|
||||
return 256
|
||||
|
||||
default:
|
||||
return maxKBitrate
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
ratio: 0,
|
||||
resolution: VideoResolution.H_NOVIDEO,
|
||||
isPortraitMode: false
|
||||
}
|
||||
}
|
||||
|
||||
if (videoStream.rotation === '90' || videoStream.rotation === '-90') {
|
||||
const width = videoStream.width
|
||||
videoStream.width = videoStream.height
|
||||
videoStream.height = width
|
||||
}
|
||||
|
||||
return {
|
||||
width: videoStream.width,
|
||||
height: videoStream.height,
|
||||
ratio: buildAspectRatio({ width: videoStream.width, height: videoStream.height }),
|
||||
resolution: Math.min(videoStream.height, videoStream.width),
|
||||
isPortraitMode: videoStream.height > videoStream.width
|
||||
}
|
||||
}
|
||||
|
||||
async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return 0
|
||||
|
||||
for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
|
||||
const valuesText: string = videoStream[key]
|
||||
if (!valuesText) continue
|
||||
|
||||
const [ frames, seconds ] = valuesText.split('/')
|
||||
if (!frames || !seconds) continue
|
||||
|
||||
const result = parseInt(frames, 10) / parseInt(seconds, 10)
|
||||
if (result > 0) return Math.round(result)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
let bitrate = metadata.format.bit_rate
|
||||
if (bitrate && !isNaN(bitrate)) return bitrate
|
||||
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return undefined
|
||||
|
||||
bitrate = forceNumber(videoStream?.bit_rate)
|
||||
if (bitrate && !isNaN(bitrate)) return bitrate
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return Math.round(metadata.format.duration)
|
||||
}
|
||||
|
||||
async function getVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||
const metadata = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return metadata.streams.find(s => s.codec_type === 'video')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getChaptersFromContainer (options: {
|
||||
path: string
|
||||
maxTitleLength: number
|
||||
ffprobe?: FfprobeData
|
||||
}) {
|
||||
const { path, maxTitleLength, ffprobe } = options
|
||||
|
||||
const metadata = ffprobe || await ffprobePromise(path)
|
||||
|
||||
if (!Array.isArray(metadata?.chapters)) return []
|
||||
|
||||
return metadata.chapters
|
||||
.map(c => ({
|
||||
timecode: Math.round(c.start_time),
|
||||
title: (c['TAG:title'] || '').slice(0, maxTitleLength)
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getVideoStreamDimensionsInfo,
|
||||
getChaptersFromContainer,
|
||||
getMaxAudioBitrate,
|
||||
getVideoStream,
|
||||
getVideoStreamDuration,
|
||||
getAudioStream,
|
||||
getVideoStreamFPS,
|
||||
isAudioFile,
|
||||
ffprobePromise,
|
||||
getVideoStreamBitrate,
|
||||
hasAudioStream
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './ffmpeg-command-wrapper.js'
|
||||
export * from './ffmpeg-default-transcoding-profile.js'
|
||||
export * from './ffmpeg-edition.js'
|
||||
export * from './ffmpeg-images.js'
|
||||
export * from './ffmpeg-live.js'
|
||||
export * from './ffmpeg-utils.js'
|
||||
export * from './ffmpeg-version.js'
|
||||
export * from './ffmpeg-vod.js'
|
||||
export * from './ffprobe.js'
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
import { EncoderOptions } from '@peertube/peertube-models'
|
||||
import { buildStreamSuffix } from '../ffmpeg-utils.js'
|
||||
|
||||
export function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
|
||||
// avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
|
||||
command.outputOption('-max_muxing_queue_size 1024')
|
||||
// strip all metadata
|
||||
.outputOption('-map_metadata -1')
|
||||
// allows import of source material with incompatible pixel formats (e.g. MJPEG video)
|
||||
.outputOption('-pix_fmt yuv420p')
|
||||
}
|
||||
|
||||
export function addDefaultEncoderParams (options: {
|
||||
command: FfmpegCommand
|
||||
encoder: 'libx264' | string
|
||||
fps: number
|
||||
|
||||
streamNum?: number
|
||||
}) {
|
||||
const { command, encoder, fps, streamNum } = options
|
||||
|
||||
if (encoder === 'libx264') {
|
||||
if (fps) {
|
||||
// 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
|
||||
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) {
|
||||
command.inputOptions(options.inputOptions ?? [])
|
||||
.outputOptions(options.outputOptions ?? [])
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './encoder-options.js'
|
||||
export * from './presets.js'
|
||||
@@ -0,0 +1,94 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper.js'
|
||||
import { getScaleFilter, StreamType } from '../ffmpeg-utils.js'
|
||||
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe.js'
|
||||
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options.js'
|
||||
|
||||
export async function presetVOD (options: {
|
||||
commandWrapper: FFmpegCommandWrapper
|
||||
|
||||
input: string
|
||||
|
||||
canCopyAudio: boolean
|
||||
canCopyVideo: boolean
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
scaleFilterValue?: string
|
||||
}) {
|
||||
const { commandWrapper, input, resolution, fps, scaleFilterValue } = options
|
||||
const command = commandWrapper.getCommand()
|
||||
|
||||
command.format('mp4')
|
||||
.outputOption('-movflags faststart')
|
||||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
const probe = await ffprobePromise(input)
|
||||
|
||||
// Audio encoder
|
||||
const bitrate = await getVideoStreamBitrate(input, probe)
|
||||
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
|
||||
|
||||
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
||||
|
||||
if (!await hasAudioStream(input, probe)) {
|
||||
command.noAudio()
|
||||
streamsToProcess = [ 'video' ]
|
||||
}
|
||||
|
||||
for (const streamType of streamsToProcess) {
|
||||
const builderResult = await commandWrapper.getEncoderBuilderResult({
|
||||
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
|
||||
|
||||
input,
|
||||
inputBitrate: bitrate,
|
||||
inputRatio: videoStreamDimensions?.ratio || 0,
|
||||
inputProbe: probe,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
streamType,
|
||||
|
||||
videoType: 'vod' as 'vod'
|
||||
})
|
||||
|
||||
if (!builderResult) {
|
||||
throw new Error('No available encoder found for stream ' + streamType)
|
||||
}
|
||||
|
||||
commandWrapper.debugLog(
|
||||
`Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` +
|
||||
`stream of input ${input} using ${commandWrapper.getProfile()} profile.`,
|
||||
{ builderResult, resolution, fps }
|
||||
)
|
||||
|
||||
if (streamType === 'video') {
|
||||
command.videoCodec(builderResult.encoder)
|
||||
|
||||
if (scaleFilterValue) {
|
||||
command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
|
||||
}
|
||||
} else if (streamType === 'audio') {
|
||||
command.audioCodec(builderResult.encoder)
|
||||
}
|
||||
|
||||
applyEncoderOptions(command, builderResult.result)
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps })
|
||||
}
|
||||
}
|
||||
|
||||
export function presetCopy (commandWrapper: FFmpegCommandWrapper) {
|
||||
commandWrapper.getCommand()
|
||||
.format('mp4')
|
||||
.videoCodec('copy')
|
||||
.audioCodec('copy')
|
||||
}
|
||||
|
||||
export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) {
|
||||
commandWrapper.getCommand()
|
||||
.format('mp4')
|
||||
.audioCodec('copy')
|
||||
.noVideo()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../models" },
|
||||
{ "path": "../core-utils" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@peertube/peertube-models",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"files": [ "dist" ],
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"peertube:tsx": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ActivityPubActor } from './activitypub-actor.js'
|
||||
import { ActivityPubSignature } from './activitypub-signature.js'
|
||||
import {
|
||||
ActivityFlagReasonObject,
|
||||
ActivityObject,
|
||||
APObjectId,
|
||||
CacheFileObject,
|
||||
PlaylistObject,
|
||||
VideoCommentObject,
|
||||
VideoObject,
|
||||
WatchActionObject
|
||||
} from './objects/index.js'
|
||||
|
||||
export type ActivityUpdateObject =
|
||||
Extract<ActivityObject, VideoObject | CacheFileObject | PlaylistObject | ActivityPubActor | string> | ActivityPubActor
|
||||
|
||||
// Cannot Extract from Activity because of circular reference
|
||||
export type ActivityUndoObject =
|
||||
ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate<CacheFileObject | string> | ActivityAnnounce
|
||||
|
||||
export type ActivityCreateObject =
|
||||
Extract<ActivityObject, VideoObject | CacheFileObject | WatchActionObject | VideoCommentObject | PlaylistObject | string>
|
||||
|
||||
export type Activity =
|
||||
ActivityCreate<ActivityCreateObject> |
|
||||
ActivityUpdate<ActivityUpdateObject> |
|
||||
ActivityDelete |
|
||||
ActivityFollow |
|
||||
ActivityAccept |
|
||||
ActivityAnnounce |
|
||||
ActivityUndo<ActivityUndoObject> |
|
||||
ActivityLike |
|
||||
ActivityReject |
|
||||
ActivityView |
|
||||
ActivityDislike |
|
||||
ActivityFlag |
|
||||
ActivityApproveReply |
|
||||
ActivityRejectReply
|
||||
|
||||
export type ActivityType =
|
||||
'Create' |
|
||||
'Update' |
|
||||
'Delete' |
|
||||
'Follow' |
|
||||
'Accept' |
|
||||
'Announce' |
|
||||
'Undo' |
|
||||
'Like' |
|
||||
'Reject' |
|
||||
'View' |
|
||||
'Dislike' |
|
||||
'Flag' |
|
||||
'ApproveReply' |
|
||||
'RejectReply'
|
||||
|
||||
export interface ActivityAudience {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
}
|
||||
|
||||
export interface BaseActivity {
|
||||
'@context'?: any[]
|
||||
id: string
|
||||
to?: string[]
|
||||
cc?: string[]
|
||||
actor: string | ActivityPubActor
|
||||
type: ActivityType
|
||||
signature?: ActivityPubSignature
|
||||
}
|
||||
|
||||
export interface ActivityCreate <T extends ActivityCreateObject> extends BaseActivity {
|
||||
type: 'Create'
|
||||
object: T
|
||||
}
|
||||
|
||||
export interface ActivityUpdate <T extends ActivityUpdateObject> extends BaseActivity {
|
||||
type: 'Update'
|
||||
object: T
|
||||
}
|
||||
|
||||
export interface ActivityDelete extends BaseActivity {
|
||||
type: 'Delete'
|
||||
object: APObjectId
|
||||
}
|
||||
|
||||
export interface ActivityFollow extends BaseActivity {
|
||||
type: 'Follow'
|
||||
object: string
|
||||
}
|
||||
|
||||
export interface ActivityAccept extends BaseActivity {
|
||||
type: 'Accept'
|
||||
object: ActivityFollow
|
||||
}
|
||||
|
||||
export interface ActivityApproveReply extends BaseActivity {
|
||||
type: 'ApproveReply'
|
||||
object: string
|
||||
inReplyTo: string
|
||||
}
|
||||
|
||||
export interface ActivityRejectReply extends BaseActivity {
|
||||
type: 'RejectReply'
|
||||
object: string
|
||||
inReplyTo: string
|
||||
}
|
||||
|
||||
export interface ActivityReject extends BaseActivity {
|
||||
type: 'Reject'
|
||||
object: ActivityFollow
|
||||
}
|
||||
|
||||
export interface ActivityAnnounce extends BaseActivity {
|
||||
type: 'Announce'
|
||||
object: APObjectId
|
||||
}
|
||||
|
||||
export interface ActivityUndo <T extends ActivityUndoObject> extends BaseActivity {
|
||||
type: 'Undo'
|
||||
object: T
|
||||
}
|
||||
|
||||
export interface ActivityLike extends BaseActivity {
|
||||
type: 'Like'
|
||||
object: APObjectId
|
||||
}
|
||||
|
||||
export interface ActivityView extends BaseActivity {
|
||||
type: 'View'
|
||||
actor: string
|
||||
object: APObjectId
|
||||
|
||||
// If sending a "viewer" event
|
||||
expires?: string
|
||||
result?: {
|
||||
type: 'InteractionCounter'
|
||||
interactionType: 'WatchAction'
|
||||
userInteractionCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActivityDislike extends BaseActivity {
|
||||
id: string
|
||||
type: 'Dislike'
|
||||
actor: string
|
||||
object: APObjectId
|
||||
}
|
||||
|
||||
export interface ActivityFlag extends BaseActivity {
|
||||
type: 'Flag'
|
||||
content: string
|
||||
object: APObjectId | APObjectId[]
|
||||
tag?: ActivityFlagReasonObject[]
|
||||
startAt?: number
|
||||
endAt?: number
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ActivityIconObject, ActivityPubAttributedTo } from './objects/common-objects.js'
|
||||
|
||||
export type ActivityPubActorType = 'Person' | 'Application' | 'Group' | 'Service' | 'Organization'
|
||||
|
||||
export interface ActivityPubActor {
|
||||
'@context': any[]
|
||||
type: ActivityPubActorType
|
||||
id: string
|
||||
following: string
|
||||
followers: string
|
||||
playlists?: string
|
||||
inbox: string
|
||||
outbox: string
|
||||
preferredUsername: string
|
||||
url: string
|
||||
name: string
|
||||
endpoints: {
|
||||
sharedInbox: string
|
||||
}
|
||||
summary: string
|
||||
attributedTo?: ActivityPubAttributedTo[]
|
||||
|
||||
support?: string
|
||||
publicKey: {
|
||||
id: string
|
||||
owner: string
|
||||
publicKeyPem: string
|
||||
}
|
||||
|
||||
// Lemmy attribute for groups
|
||||
postingRestrictedToMods?: boolean
|
||||
|
||||
image?: ActivityIconObject | ActivityIconObject[]
|
||||
icon?: ActivityIconObject | ActivityIconObject[]
|
||||
|
||||
published?: string
|
||||
|
||||
// For export
|
||||
likes?: string
|
||||
dislikes?: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Activity } from './activity.js'
|
||||
|
||||
export interface ActivityPubCollection {
|
||||
'@context': any[]
|
||||
type: 'Collection' | 'CollectionPage'
|
||||
totalItems: number
|
||||
partOf?: string
|
||||
items: Activity[]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface ActivityPubOrderedCollection<T> {
|
||||
id: string
|
||||
|
||||
'@context': any[]
|
||||
type: 'OrderedCollection' | 'OrderedCollectionPage'
|
||||
totalItems: number
|
||||
orderedItems: T[]
|
||||
|
||||
partOf?: string
|
||||
next?: string
|
||||
first?: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Activity } from './activity.js'
|
||||
import { ActivityPubCollection } from './activitypub-collection.js'
|
||||
import { ActivityPubOrderedCollection } from './activitypub-ordered-collection.js'
|
||||
|
||||
export type RootActivity = Activity | ActivityPubCollection | ActivityPubOrderedCollection<Activity>
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ActivityPubSignature {
|
||||
type: string
|
||||
created: Date
|
||||
creator: string
|
||||
signatureValue: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type ContextType =
|
||||
'Video' |
|
||||
'Comment' |
|
||||
'Playlist' |
|
||||
'Follow' |
|
||||
'Reject' |
|
||||
'Accept' |
|
||||
'View' |
|
||||
'Announce' |
|
||||
'CacheFile' |
|
||||
'Delete' |
|
||||
'Rate' |
|
||||
'Flag' |
|
||||
'Actor' |
|
||||
'Collection' |
|
||||
'WatchAction' |
|
||||
'Chapters' |
|
||||
'ApproveReply' |
|
||||
'RejectReply'
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './objects/index.js'
|
||||
export * from './activity.js'
|
||||
export * from './activitypub-actor.js'
|
||||
export * from './activitypub-collection.js'
|
||||
export * from './activitypub-ordered-collection.js'
|
||||
export * from './activitypub-root.js'
|
||||
export * from './activitypub-signature.js'
|
||||
export * from './context.js'
|
||||
export * from './webfinger.js'
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ActivityFlagReasonObject } from './common-objects.js'
|
||||
|
||||
export interface AbuseObject {
|
||||
type: 'Flag'
|
||||
|
||||
content: string
|
||||
mediaType: 'text/markdown'
|
||||
|
||||
object: string | string[]
|
||||
|
||||
tag?: ActivityFlagReasonObject[]
|
||||
|
||||
startAt?: number
|
||||
endAt?: number
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AbuseObject } from './abuse-object.js'
|
||||
import { CacheFileObject } from './cache-file-object.js'
|
||||
import { PlaylistObject } from './playlist-object.js'
|
||||
import { VideoCommentObject } from './video-comment-object.js'
|
||||
import { VideoObject } from './video-object.js'
|
||||
import { WatchActionObject } from './watch-action-object.js'
|
||||
|
||||
export type ActivityObject =
|
||||
VideoObject |
|
||||
AbuseObject |
|
||||
VideoCommentObject |
|
||||
CacheFileObject |
|
||||
PlaylistObject |
|
||||
WatchActionObject |
|
||||
string
|
||||
|
||||
export type APObjectId = string | { id: string }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ActivityVideoUrlObject, ActivityPlaylistUrlObject } from './common-objects.js'
|
||||
|
||||
export interface CacheFileObject {
|
||||
id: string
|
||||
type: 'CacheFile'
|
||||
object: string
|
||||
expires: string
|
||||
url: ActivityVideoUrlObject | ActivityPlaylistUrlObject
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { AbusePredefinedReasonsString } from '../../moderation/abuse/abuse-reason.model.js'
|
||||
|
||||
export interface ActivityIdentifierObject {
|
||||
identifier: string
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ActivityIconObject {
|
||||
type: 'Image'
|
||||
url: string
|
||||
mediaType: string
|
||||
width: number
|
||||
height: number | null
|
||||
}
|
||||
|
||||
export type ActivityVideoUrlObject = {
|
||||
type: 'Link'
|
||||
mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4'
|
||||
href: string
|
||||
height: number
|
||||
width: number | null
|
||||
size: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export type ActivityPlaylistSegmentHashesObject = {
|
||||
type: 'Link'
|
||||
name: 'sha256'
|
||||
mediaType: 'application/json'
|
||||
href: string
|
||||
}
|
||||
|
||||
export type ActivityVideoFileMetadataUrlObject = {
|
||||
type: 'Link'
|
||||
rel: [ 'metadata', any ]
|
||||
mediaType: 'application/json'
|
||||
height: number
|
||||
width: number | null
|
||||
href: string
|
||||
fps: number
|
||||
}
|
||||
|
||||
export type ActivityTrackerUrlObject = {
|
||||
type: 'Link'
|
||||
rel: [ 'tracker', 'websocket' | 'http' ]
|
||||
name: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type ActivityStreamingPlaylistInfohashesObject = {
|
||||
type: 'Infohash'
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ActivityPlaylistUrlObject = {
|
||||
type: 'Link'
|
||||
mediaType: 'application/x-mpegURL'
|
||||
href: string
|
||||
tag?: ActivityTagObject[]
|
||||
}
|
||||
|
||||
export type ActivityBitTorrentUrlObject = {
|
||||
type: 'Link'
|
||||
mediaType: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet'
|
||||
href: string
|
||||
height: number
|
||||
width: number | null
|
||||
fps: number | null
|
||||
}
|
||||
|
||||
export type ActivityMagnetUrlObject = {
|
||||
type: 'Link'
|
||||
mediaType: 'application/x-bittorrent;x-scheme-handler/magnet'
|
||||
href: string
|
||||
height: number
|
||||
width: number | null
|
||||
fps: number | null
|
||||
}
|
||||
|
||||
export type ActivityHtmlUrlObject = {
|
||||
type: 'Link'
|
||||
mediaType: 'text/html'
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface ActivityHashTagObject {
|
||||
type: 'Hashtag'
|
||||
href?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ActivityMentionObject {
|
||||
type: 'Mention'
|
||||
href?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ActivityFlagReasonObject {
|
||||
type: 'Hashtag'
|
||||
name: AbusePredefinedReasonsString
|
||||
}
|
||||
|
||||
export type ActivityTagObject =
|
||||
ActivityPlaylistSegmentHashesObject
|
||||
| ActivityStreamingPlaylistInfohashesObject
|
||||
| ActivityVideoUrlObject
|
||||
| ActivityHashTagObject
|
||||
| ActivityMentionObject
|
||||
| ActivityBitTorrentUrlObject
|
||||
| ActivityMagnetUrlObject
|
||||
| ActivityVideoFileMetadataUrlObject
|
||||
|
||||
export type ActivityUrlObject =
|
||||
ActivityVideoUrlObject
|
||||
| ActivityPlaylistUrlObject
|
||||
| ActivityBitTorrentUrlObject
|
||||
| ActivityMagnetUrlObject
|
||||
| ActivityHtmlUrlObject
|
||||
| ActivityVideoFileMetadataUrlObject
|
||||
| ActivityTrackerUrlObject
|
||||
|
||||
export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string
|
||||
|
||||
export interface ActivityTombstoneObject {
|
||||
'@context'?: any
|
||||
id: string
|
||||
url?: string
|
||||
type: 'Tombstone'
|
||||
name?: string
|
||||
formerType?: string
|
||||
inReplyTo?: string
|
||||
published: string
|
||||
updated: string
|
||||
deleted: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './abuse-object.js'
|
||||
export * from './activitypub-object.js'
|
||||
export * from './cache-file-object.js'
|
||||
export * from './common-objects.js'
|
||||
export * from './playlist-element-object.js'
|
||||
export * from './playlist-object.js'
|
||||
export * from './video-caption-object.js'
|
||||
export * from './video-chapters-object.js'
|
||||
export * from './video-comment-object.js'
|
||||
export * from './video-object.js'
|
||||
export * from './watch-action-object.js'
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface PlaylistElementObject {
|
||||
id: string
|
||||
type: 'PlaylistElement'
|
||||
|
||||
url: string
|
||||
position: number
|
||||
|
||||
startTimestamp?: number
|
||||
stopTimestamp?: number
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ActivityIconObject, ActivityPubAttributedTo } from './common-objects.js'
|
||||
|
||||
export interface PlaylistObject {
|
||||
id: string
|
||||
type: 'Playlist'
|
||||
|
||||
name: string
|
||||
|
||||
content: string
|
||||
mediaType: 'text/markdown'
|
||||
|
||||
uuid: string
|
||||
|
||||
totalItems: number
|
||||
attributedTo: ActivityPubAttributedTo[]
|
||||
|
||||
icon?: ActivityIconObject
|
||||
|
||||
published: string
|
||||
updated: string
|
||||
|
||||
orderedItems?: string[]
|
||||
|
||||
partOf?: string
|
||||
next?: string
|
||||
first?: string
|
||||
|
||||
to?: string[]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ActivityIdentifierObject } from './common-objects.js'
|
||||
|
||||
export interface VideoCaptionObject extends ActivityIdentifierObject {
|
||||
automaticallyGenerated: boolean
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface VideoChaptersObject {
|
||||
id: string
|
||||
hasPart: VideoChapterObject[]
|
||||
}
|
||||
|
||||
// Same as https://schema.org/hasPart
|
||||
export interface VideoChapterObject {
|
||||
name: string
|
||||
startOffset: number
|
||||
endOffset: number
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ActivityPubAttributedTo, ActivityTagObject } from './common-objects.js'
|
||||
|
||||
export interface VideoCommentObject {
|
||||
type: 'Note'
|
||||
id: string
|
||||
|
||||
content: string
|
||||
mediaType: 'text/markdown'
|
||||
|
||||
inReplyTo: string
|
||||
published: string
|
||||
updated: string
|
||||
url: string
|
||||
attributedTo: ActivityPubAttributedTo
|
||||
tag: ActivityTagObject[]
|
||||
|
||||
replyApproval: string | null
|
||||
|
||||
to?: string[]
|
||||
cc?: string[]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { LiveVideoLatencyModeType, VideoCommentPolicyType, VideoStateType } from '../../videos/index.js'
|
||||
import {
|
||||
ActivityIconObject,
|
||||
ActivityIdentifierObject,
|
||||
ActivityPubAttributedTo,
|
||||
ActivityTagObject,
|
||||
ActivityUrlObject
|
||||
} from './common-objects.js'
|
||||
import { VideoCaptionObject } from './video-caption-object.js'
|
||||
import { VideoChapterObject } from './video-chapters-object.js'
|
||||
|
||||
export interface VideoObject {
|
||||
type: 'Video'
|
||||
id: string
|
||||
name: string
|
||||
duration: string
|
||||
uuid: string
|
||||
tag: ActivityTagObject[]
|
||||
category: ActivityIdentifierObject
|
||||
licence: ActivityIdentifierObject
|
||||
language: ActivityIdentifierObject
|
||||
subtitleLanguage: VideoCaptionObject[]
|
||||
|
||||
views: number
|
||||
|
||||
sensitive: boolean
|
||||
|
||||
isLiveBroadcast: boolean
|
||||
liveSaveReplay: boolean
|
||||
permanentLive: boolean
|
||||
latencyMode: LiveVideoLatencyModeType
|
||||
|
||||
commentsEnabled?: boolean
|
||||
commentsPolicy: VideoCommentPolicyType
|
||||
canReply: 'as:Public' | 'https://www.w3.org/ns/activitystreams#Public'
|
||||
|
||||
downloadEnabled: boolean
|
||||
waitTranscoding: boolean
|
||||
state: VideoStateType
|
||||
|
||||
published: string
|
||||
originallyPublishedAt: string
|
||||
updated: string
|
||||
uploadDate: string
|
||||
|
||||
mediaType: 'text/markdown'
|
||||
content: string
|
||||
|
||||
support: string
|
||||
|
||||
aspectRatio: number
|
||||
|
||||
icon: ActivityIconObject[]
|
||||
|
||||
url: ActivityUrlObject[]
|
||||
|
||||
likes: string
|
||||
dislikes: string
|
||||
shares: string
|
||||
comments: string
|
||||
hasParts: string | VideoChapterObject[]
|
||||
|
||||
attributedTo: ActivityPubAttributedTo[]
|
||||
|
||||
preview?: ActivityPubStoryboard[]
|
||||
|
||||
to?: string[]
|
||||
cc?: string[]
|
||||
|
||||
// For export
|
||||
attachment?: {
|
||||
type: 'Video'
|
||||
url: string
|
||||
mediaType: string
|
||||
height: number
|
||||
size: number
|
||||
fps: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface ActivityPubStoryboard {
|
||||
type: 'Image'
|
||||
rel: [ 'storyboard' ]
|
||||
url: {
|
||||
href: string
|
||||
mediaType: string
|
||||
width: number
|
||||
height: number
|
||||
tileWidth: number
|
||||
tileHeight: number
|
||||
tileDuration: string
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface WatchActionObject {
|
||||
id: string
|
||||
type: 'WatchAction'
|
||||
|
||||
startTime: string
|
||||
endTime: string
|
||||
|
||||
location?: {
|
||||
addressCountry: string
|
||||
addressRegion: string
|
||||
}
|
||||
|
||||
uuid: string
|
||||
object: string
|
||||
actionStatus: 'CompletedActionStatus'
|
||||
|
||||
duration: string
|
||||
|
||||
watchSections: {
|
||||
startTimestamp: number
|
||||
endTimestamp: number
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface WebFingerData {
|
||||
subject: string
|
||||
aliases: string[]
|
||||
links: {
|
||||
rel: 'self'
|
||||
type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
|
||||
href: string
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ActorImage } from './actor-image.model.js'
|
||||
import { Actor } from './actor.model.js'
|
||||
|
||||
export interface Account extends Actor {
|
||||
displayName: string
|
||||
description: string
|
||||
avatars: ActorImage[]
|
||||
|
||||
updatedAt: Date | string
|
||||
|
||||
userId?: number
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: number
|
||||
name: string
|
||||
displayName: string
|
||||
url: string
|
||||
host: string
|
||||
|
||||
avatars: ActorImage[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ActorImage {
|
||||
width: number
|
||||
path: string
|
||||
|
||||
url?: string
|
||||
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const ActorImageType = {
|
||||
AVATAR: 1,
|
||||
BANNER: 2
|
||||
} as const
|
||||
|
||||
export type ActorImageType_Type = typeof ActorImageType[keyof typeof ActorImageType]
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ActorImage } from './actor-image.model.js'
|
||||
|
||||
export interface Actor {
|
||||
id: number
|
||||
url: string
|
||||
name: string
|
||||
host: string
|
||||
followingCount: number
|
||||
followersCount: number
|
||||
createdAt: Date | string
|
||||
|
||||
avatars: ActorImage[]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface CustomPage {
|
||||
content: string
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Actor } from './actor.model.js'
|
||||
|
||||
export type FollowState = 'pending' | 'accepted' | 'rejected'
|
||||
|
||||
export interface ActorFollow {
|
||||
id: number
|
||||
follower: Actor & { hostRedundancyAllowed: boolean }
|
||||
following: Actor & { hostRedundancyAllowed: boolean }
|
||||
score: number
|
||||
state: FollowState
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './account.model.js'
|
||||
export * from './actor-image.model.js'
|
||||
export * from './actor-image.type.js'
|
||||
export * from './actor.model.js'
|
||||
export * from './custom-page.model.js'
|
||||
export * from './follow.model.js'
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface BulkRemoveCommentsOfBody {
|
||||
accountName: string
|
||||
scope: 'my-videos' | 'instance'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './bulk-remove-comments-of-body.model.js'
|
||||
@@ -0,0 +1,6 @@
|
||||
export const FileStorage = {
|
||||
FILE_SYSTEM: 0,
|
||||
OBJECT_STORAGE: 1
|
||||
} as const
|
||||
|
||||
export type FileStorageType = typeof FileStorage[keyof typeof FileStorage]
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './file-storage.enum.js'
|
||||
export * from './result-list.model.js'
|
||||
export * from './simple-logger.model.js'
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ResultList<T> {
|
||||
total: number
|
||||
data: T[]
|
||||
}
|
||||
|
||||
export interface ThreadsResultList <T> extends ResultList <T> {
|
||||
totalNotDeletedComments: number
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SimpleLogger = {
|
||||
info: (msg: string, obj?: object) => void
|
||||
debug: (msg: string, obj?: object) => void
|
||||
warn: (msg: string, obj?: object) => void
|
||||
error: (msg: string, obj?: object) => void
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
type StringBoolean = 'true' | 'false'
|
||||
|
||||
export type EmbedMarkupData = {
|
||||
// Video or playlist uuid
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export type VideoMiniatureMarkupData = {
|
||||
// Video uuid
|
||||
uuid: string
|
||||
|
||||
onlyDisplayTitle?: StringBoolean
|
||||
}
|
||||
|
||||
export type PlaylistMiniatureMarkupData = {
|
||||
// Playlist uuid
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export type ChannelMiniatureMarkupData = {
|
||||
// Channel name (username)
|
||||
name: string
|
||||
|
||||
displayLatestVideo?: StringBoolean
|
||||
displayDescription?: StringBoolean
|
||||
}
|
||||
|
||||
export type VideosListMarkupData = {
|
||||
onlyDisplayTitle?: StringBoolean
|
||||
maxRows?: string // number
|
||||
|
||||
sort?: string
|
||||
count?: string // number
|
||||
|
||||
categoryOneOf?: string // coma separated values, number[]
|
||||
languageOneOf?: string // coma separated values
|
||||
|
||||
channelHandle?: string
|
||||
accountHandle?: string
|
||||
|
||||
isLive?: string // number
|
||||
|
||||
onlyLocal?: StringBoolean
|
||||
}
|
||||
|
||||
export type ButtonMarkupData = {
|
||||
theme: 'primary' | 'secondary'
|
||||
href: string
|
||||
label: string
|
||||
blankTarget?: StringBoolean
|
||||
}
|
||||
|
||||
export type ContainerMarkupData = {
|
||||
width?: string
|
||||
title?: string
|
||||
description?: string
|
||||
layout?: 'row' | 'column'
|
||||
|
||||
justifyContent?: 'space-between' | 'normal' // default to 'space-between'
|
||||
}
|
||||
|
||||
export type InstanceBannerMarkupData = {
|
||||
revertHomePaddingTop?: StringBoolean // default to 'true'
|
||||
}
|
||||
|
||||
export type InstanceAvatarMarkupData = {
|
||||
size: string // size in pixels
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './custom-markup-data.model.js'
|
||||
@@ -0,0 +1,7 @@
|
||||
export const FeedFormat = {
|
||||
RSS: 'xml',
|
||||
ATOM: 'atom',
|
||||
JSON: 'json'
|
||||
} as const
|
||||
|
||||
export type FeedFormatType = typeof FeedFormat[keyof typeof FeedFormat]
|
||||
@@ -0,0 +1 @@
|
||||
export * from './feed-format.enum.js'
|
||||
@@ -0,0 +1,23 @@
|
||||
/** HTTP request method to indicate the desired action to be performed for a given resource. */
|
||||
export const HttpMethod = {
|
||||
/** The CONNECT method establishes a tunnel to the server identified by the target resource. */
|
||||
CONNECT: 'CONNECT',
|
||||
/** The DELETE method deletes the specified resource. */
|
||||
DELETE: 'DELETE',
|
||||
/** The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. */
|
||||
GET: 'GET',
|
||||
/** The HEAD method asks for a response identical to that of a GET request, but without the response body. */
|
||||
HEAD: 'HEAD',
|
||||
/** The OPTIONS method is used to describe the communication options for the target resource. */
|
||||
OPTIONS: 'OPTIONS',
|
||||
/** The PATCH method is used to apply partial modifications to a resource. */
|
||||
PATCH: 'PATCH',
|
||||
/** The POST method is used to submit an entity to the specified resource */
|
||||
POST: 'POST',
|
||||
/** The PUT method replaces all current representations of the target resource with the request payload. */
|
||||
PUT: 'PUT',
|
||||
/** The TRACE method performs a message loop-back test along the path to the target resource. */
|
||||
TRACE: 'TRACE'
|
||||
} as const
|
||||
|
||||
export type HttpMethodType = typeof HttpMethod[keyof typeof HttpMethod]
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Hypertext Transfer Protocol (HTTP) response status codes.
|
||||
* @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
|
||||
*
|
||||
* WebDAV and other codes useless with regards to PeerTube are not listed.
|
||||
*/
|
||||
export const HttpStatusCode = {
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1
|
||||
*
|
||||
* The server has received the request headers and the client should proceed to send the request body
|
||||
* (in the case of a request for which a body needs to be sent; for example, a POST request).
|
||||
* Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient.
|
||||
* To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request
|
||||
* and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates
|
||||
* the request should not be continued.
|
||||
*/
|
||||
CONTINUE_100: 100,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2
|
||||
*
|
||||
* This code is sent in response to an Upgrade request header by the client, and indicates the protocol the server is switching too.
|
||||
*/
|
||||
SWITCHING_PROTOCOLS_101: 101,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1
|
||||
*
|
||||
* Standard response for successful HTTP requests. The actual response will depend on the request method used:
|
||||
* GET: The resource has been fetched and is transmitted in the message body.
|
||||
* HEAD: The entity headers are in the message body.
|
||||
* POST: The resource describing the result of the action is transmitted in the message body.
|
||||
* TRACE: The message body contains the request message as received by the server
|
||||
*/
|
||||
OK_200: 200,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2
|
||||
*
|
||||
* The request has been fulfilled, resulting in the creation of a new resource, typically after a PUT.
|
||||
*/
|
||||
CREATED_201: 201,
|
||||
|
||||
/**
|
||||
* The request has been accepted for processing, but the processing has not been completed.
|
||||
* The request might or might not be eventually acted upon, and may be disallowed when processing occurs.
|
||||
*/
|
||||
ACCEPTED_202: 202,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5
|
||||
*
|
||||
* There is no content to send for this request, but the headers may be useful.
|
||||
* The user-agent may update its cached headers for this resource with the new ones.
|
||||
*/
|
||||
NO_CONTENT_204: 204,
|
||||
|
||||
/**
|
||||
* The server successfully processed the request, but is not returning any content.
|
||||
* Unlike a 204 response, this response requires that the requester reset the document view.
|
||||
*/
|
||||
RESET_CONTENT_205: 205,
|
||||
|
||||
/**
|
||||
* The server is delivering only part of the resource (byte serving) due to a range header sent by the client.
|
||||
* The range header is used by HTTP clients to enable resuming of interrupted downloads,
|
||||
* or split a download into multiple simultaneous streams.
|
||||
*/
|
||||
PARTIAL_CONTENT_206: 206,
|
||||
|
||||
/**
|
||||
* Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation).
|
||||
* For example, this code could be used to present multiple video format options,
|
||||
* to list files with different filename extensions, or to suggest word-sense disambiguation.
|
||||
*/
|
||||
MULTIPLE_CHOICES_300: 300,
|
||||
|
||||
/**
|
||||
* This and all future requests should be directed to the given URI.
|
||||
*/
|
||||
MOVED_PERMANENTLY_301: 301,
|
||||
|
||||
/**
|
||||
* This is an example of industry practice contradicting the standard.
|
||||
* The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect
|
||||
* (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302
|
||||
* with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307
|
||||
* to distinguish between the two behaviours. However, some Web applications and frameworks
|
||||
* use the 302 status code as if it were the 303.
|
||||
*/
|
||||
FOUND_302: 302,
|
||||
|
||||
/**
|
||||
* SINCE HTTP/1.1
|
||||
* The response to the request can be found under another URI using a GET method.
|
||||
* When received in response to a POST (or PUT/DELETE), the client should presume that
|
||||
* the server has received the data and should issue a redirect with a separate GET message.
|
||||
*/
|
||||
SEE_OTHER_303: 303,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1
|
||||
*
|
||||
* Indicates that the resource has not been modified since the version specified by the request headers
|
||||
* `If-Modified-Since` or `If-None-Match`.
|
||||
* In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.
|
||||
*/
|
||||
NOT_MODIFIED_304: 304,
|
||||
|
||||
/**
|
||||
* SINCE HTTP/1.1
|
||||
* In this case, the request should be repeated with another URI; however, future requests should still use the original URI.
|
||||
* In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the
|
||||
* original request.
|
||||
* For example, a POST request should be repeated using another POST request.
|
||||
*/
|
||||
TEMPORARY_REDIRECT_307: 307,
|
||||
|
||||
/**
|
||||
* The request and all future requests should be repeated using another URI.
|
||||
* 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change.
|
||||
* So, for example, submitting a form to a permanently redirected resource may continue smoothly.
|
||||
*/
|
||||
PERMANENT_REDIRECT_308: 308,
|
||||
|
||||
/**
|
||||
* The server cannot or will not process the request due to an apparent client error
|
||||
* (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing).
|
||||
*/
|
||||
BAD_REQUEST_400: 400,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1
|
||||
*
|
||||
* Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet
|
||||
* been provided. The response must include a `WWW-Authenticate` header field containing a challenge applicable to the
|
||||
* requested resource. See Basic access authentication and Digest access authentication. 401 semantically means
|
||||
* "unauthenticated",i.e. the user does not have the necessary credentials.
|
||||
*/
|
||||
UNAUTHORIZED_401: 401,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2
|
||||
*
|
||||
* Reserved for future use. The original intention was that this code might be used as part of some form of digital
|
||||
* cash or micro payment scheme, but that has not happened, and this code is not usually used.
|
||||
* Google Developers API uses this status if a particular developer has exceeded the daily limit on requests.
|
||||
*/
|
||||
PAYMENT_REQUIRED_402: 402,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3
|
||||
*
|
||||
* The client does not have access rights to the content, i.e. they are unauthorized, so server is rejecting to
|
||||
* give proper response. Unlike 401, the client's identity is known to the server.
|
||||
*/
|
||||
FORBIDDEN_403: 403,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2
|
||||
*
|
||||
* The requested resource could not be found but may be available in the future.
|
||||
* Subsequent requests by the client are permissible.
|
||||
*/
|
||||
NOT_FOUND_404: 404,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5
|
||||
*
|
||||
* A request method is not supported for the requested resource;
|
||||
* for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.
|
||||
*/
|
||||
METHOD_NOT_ALLOWED_405: 405,
|
||||
|
||||
/**
|
||||
* The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
|
||||
*/
|
||||
NOT_ACCEPTABLE_406: 406,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7
|
||||
*
|
||||
* This response is sent on an idle connection by some servers, even without any previous request by the client.
|
||||
* It means that the server would like to shut down this unused connection. This response is used much more since
|
||||
* some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also
|
||||
* note that some servers merely shut down the connection without sending this message.
|
||||
*
|
||||
* @
|
||||
*/
|
||||
REQUEST_TIMEOUT_408: 408,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8
|
||||
*
|
||||
* Indicates that the request could not be processed because of conflict in the request,
|
||||
* such as an edit conflict between multiple simultaneous updates.
|
||||
*
|
||||
* @see HttpStatusCode.UNPROCESSABLE_ENTITY_422 to denote a disabled feature
|
||||
*/
|
||||
CONFLICT_409: 409,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9
|
||||
*
|
||||
* Indicates that the resource requested is no longer available and will not be available again.
|
||||
* This should be used when a resource has been intentionally removed and the resource should be purged.
|
||||
* Upon receiving a 410 status code, the client should not request the resource in the future.
|
||||
* Clients such as search engines should remove the resource from their indices.
|
||||
* Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead.
|
||||
*/
|
||||
GONE_410: 410,
|
||||
|
||||
/**
|
||||
* The request did not specify the length of its content, which is required by the requested resource.
|
||||
*/
|
||||
LENGTH_REQUIRED_411: 411,
|
||||
|
||||
/**
|
||||
* The server does not meet one of the preconditions that the requester put on the request.
|
||||
*/
|
||||
PRECONDITION_FAILED_412: 412,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11
|
||||
*
|
||||
* The request is larger than the server is willing or able to process ; the server might close the connection
|
||||
* or return an Retry-After header field.
|
||||
* Previously called "Request Entity Too Large".
|
||||
*/
|
||||
PAYLOAD_TOO_LARGE_413: 413,
|
||||
|
||||
/**
|
||||
* The URI provided was too long for the server to process. Often the result of too much data being encoded as a
|
||||
* query-string of a GET request, in which case it should be converted to a POST request.
|
||||
* Called "Request-URI Too Long" previously.
|
||||
*/
|
||||
URI_TOO_LONG_414: 414,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13
|
||||
*
|
||||
* The request entity has a media type which the server or resource does not support.
|
||||
* For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.
|
||||
*/
|
||||
UNSUPPORTED_MEDIA_TYPE_415: 415,
|
||||
|
||||
/**
|
||||
* The client has asked for a portion of the file (byte serving), but the server cannot supply that portion.
|
||||
* For example, if the client asked for a part of the file that lies beyond the end of the file.
|
||||
* Called "Requested Range Not Satisfiable" previously.
|
||||
*/
|
||||
RANGE_NOT_SATISFIABLE_416: 416,
|
||||
|
||||
/**
|
||||
* The server cannot meet the requirements of the `Expect` request-header field.
|
||||
*/
|
||||
EXPECTATION_FAILED_417: 417,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc2324
|
||||
*
|
||||
* This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol,
|
||||
* and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by
|
||||
* teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including PeerTube instances ;-).
|
||||
*/
|
||||
I_AM_A_TEAPOT_418: 418,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3
|
||||
*
|
||||
* The request was well-formed but was unable to be followed due to semantic errors.
|
||||
* The server understands the content type of the request entity (hence a 415 (Unsupported Media Type) status code is inappropriate),
|
||||
* and the syntax of the request entity is correct (thus a 400 (Bad Request) status code is inappropriate) but was unable to process
|
||||
* the contained instructions. For example, this error condition may occur if an JSON request body contains well-formed (i.e.,
|
||||
* syntactically correct), but semantically erroneous, JSON instructions.
|
||||
*
|
||||
* Can also be used to denote disabled features (akin to disabled syntax).
|
||||
*
|
||||
* @see HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 if the `Content-Type` was not supported.
|
||||
* @see HttpStatusCode.BAD_REQUEST_400 if the request was not parsable (broken JSON, XML)
|
||||
*/
|
||||
UNPROCESSABLE_ENTITY_422: 422,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc4918#section-11.3
|
||||
*
|
||||
* The resource that is being accessed is locked. WebDAV-specific but used by some HTTP services.
|
||||
*
|
||||
* @deprecated use `If-Match` / `If-None-Match` instead
|
||||
* @see {@link https://evertpot.com/http/423-locked}
|
||||
*/
|
||||
LOCKED_423: 423,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4
|
||||
*
|
||||
* The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.
|
||||
*/
|
||||
TOO_MANY_REQUESTS_429: 429,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5
|
||||
*
|
||||
* The server is unwilling to process the request because either an individual header field,
|
||||
* or all the header fields collectively, are too large.
|
||||
*/
|
||||
REQUEST_HEADER_FIELDS_TOO_LARGE_431: 431,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7725
|
||||
*
|
||||
* A server operator has received a legal demand to deny access to a resource or to a set of resources
|
||||
* that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451.
|
||||
*/
|
||||
UNAVAILABLE_FOR_LEGAL_REASONS_451: 451,
|
||||
|
||||
/**
|
||||
* A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
|
||||
*/
|
||||
INTERNAL_SERVER_ERROR_500: 500,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2
|
||||
*
|
||||
* The server either does not recognize the request method, or it lacks the ability to fulfill the request.
|
||||
* Usually this implies future availability (e.g., a new feature of a web-service API).
|
||||
*/
|
||||
NOT_IMPLEMENTED_501: 501,
|
||||
|
||||
/**
|
||||
* The server was acting as a gateway or proxy and received an invalid response from the upstream server.
|
||||
*/
|
||||
BAD_GATEWAY_502: 502,
|
||||
|
||||
/**
|
||||
* The server is currently unavailable (because it is overloaded or down for maintenance).
|
||||
* Generally, this is a temporary state.
|
||||
*/
|
||||
SERVICE_UNAVAILABLE_503: 503,
|
||||
|
||||
/**
|
||||
* The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
|
||||
*/
|
||||
GATEWAY_TIMEOUT_504: 504,
|
||||
|
||||
/**
|
||||
* The server does not support the HTTP protocol version used in the request
|
||||
*/
|
||||
HTTP_VERSION_NOT_SUPPORTED_505: 505,
|
||||
|
||||
/**
|
||||
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6
|
||||
*
|
||||
* The 507 (Insufficient Storage) status code means the method could not be performed on the resource because the
|
||||
* server is unable to store the representation needed to successfully complete the request. This condition is
|
||||
* considered to be temporary. If the request which received this status code was the result of a user action,
|
||||
* the request MUST NOT be repeated until it is requested by a separate user action.
|
||||
*
|
||||
* @see HttpStatusCode.PAYLOAD_TOO_LARGE_413 for quota errors
|
||||
*/
|
||||
INSUFFICIENT_STORAGE_507: 507
|
||||
} as const
|
||||
|
||||
export type HttpStatusCodeType = typeof HttpStatusCode[keyof typeof HttpStatusCode]
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './http-status-codes.js'
|
||||
export * from './http-methods.js'
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './peertube-export-format/index.js'
|
||||
export * from './user-export-request-result.model.js'
|
||||
export * from './user-export-request.model.js'
|
||||
export * from './user-export-state.enum.js'
|
||||
export * from './user-export.model.js'
|
||||
export * from './user-import.model.js'
|
||||
export * from './user-import-state.enum.js'
|
||||
export * from './user-import-result.model.js'
|
||||
export * from './user-import-upload-result.model.js'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { UserActorImageJSON } from './actor-export.model.js'
|
||||
|
||||
export interface AccountExportJSON {
|
||||
url: string
|
||||
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
|
||||
avatars: UserActorImageJSON[]
|
||||
|
||||
archiveFiles: {
|
||||
avatar: string | null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface UserActorImageJSON {
|
||||
width: number
|
||||
url: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AutoTagPoliciesJSON {
|
||||
reviewComments: {
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface BlocklistExportJSON {
|
||||
instances: {
|
||||
host: string
|
||||
}[]
|
||||
|
||||
actors: {
|
||||
handle: string
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { UserActorImageJSON } from './actor-export.model.js'
|
||||
|
||||
export interface ChannelExportJSON {
|
||||
channels: {
|
||||
url: string
|
||||
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
support: string
|
||||
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
|
||||
avatars: UserActorImageJSON[]
|
||||
banners: UserActorImageJSON[]
|
||||
|
||||
archiveFiles: {
|
||||
avatar: string | null
|
||||
banner: string | null
|
||||
}
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface CommentsExportJSON {
|
||||
comments: {
|
||||
url: string
|
||||
text: string
|
||||
createdAt: string
|
||||
videoUrl: string
|
||||
|
||||
inReplyToCommentUrl?: string
|
||||
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface DislikesExportJSON {
|
||||
dislikes: {
|
||||
videoUrl: string
|
||||
createdAt: string
|
||||
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface FollowersExportJSON {
|
||||
followers: {
|
||||
handle: string
|
||||
createdAt: string
|
||||
targetHandle: string
|
||||
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface FollowingExportJSON {
|
||||
following: {
|
||||
handle: string
|
||||
targetHandle: string
|
||||
createdAt: string
|
||||
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from './account-export.model.js'
|
||||
export * from './actor-export.model.js'
|
||||
export * from './auto-tag-policies-export.js'
|
||||
export * from './blocklist-export.model.js'
|
||||
export * from './channel-export.model.js'
|
||||
export * from './comments-export.model.js'
|
||||
export * from './dislikes-export.model.js'
|
||||
export * from './followers-export.model.js'
|
||||
export * from './following-export.model.js'
|
||||
export * from './likes-export.model.js'
|
||||
export * from './user-settings-export.model.js'
|
||||
export * from './user-video-history-export.js'
|
||||
export * from './video-export.model.js'
|
||||
export * from './video-playlists-export.model.js'
|
||||
export * from './watched-words-lists-export.js'
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface LikesExportJSON {
|
||||
likes: {
|
||||
videoUrl: string
|
||||
createdAt: string
|
||||
|
||||
archiveFiles?: never
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { UserNotificationSetting } from '../../users/user-notification-setting.model.js'
|
||||
import { NSFWPolicyType } from '../../videos/nsfw-policy.type.js'
|
||||
|
||||
export interface UserSettingsExportJSON {
|
||||
email: string
|
||||
|
||||
emailPublic: boolean
|
||||
nsfwPolicy: NSFWPolicyType
|
||||
|
||||
autoPlayVideo: boolean
|
||||
autoPlayNextVideo: boolean
|
||||
autoPlayNextVideoPlaylist: boolean
|
||||
|
||||
p2pEnabled: boolean
|
||||
|
||||
videosHistoryEnabled: boolean
|
||||
videoLanguages: string[]
|
||||
|
||||
theme: string
|
||||
|
||||
createdAt: Date
|
||||
|
||||
notificationSettings: UserNotificationSetting
|
||||
|
||||
archiveFiles?: never
|
||||
}
|
||||
変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示
新しい課題から参照
ユーザをブロックする