Merge branch 'release/v8.1.x' into develop

This commit is contained in:
Chocobozzz
2026-03-18 10:55:05 +01:00
33 changed files with 289 additions and 165 deletions
+1
View File
@@ -33,6 +33,7 @@
* Classic installation: `cd /var/www/peertube/peertube-latest && sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production node dist/scripts/migrations/peertube-8.1.js`
* Docker installation: `cd /var/www/peertube-docker && docker compose exec -u peertube peertube node dist/scripts/migrations/peertube-8.1.js`
* Running [regenerate-thumbnails](https://docs.joinpeertube.org/maintain/tools#regenerate-video-thumbnails) and [prune-storage](https://docs.joinpeertube.org/maintain/tools#prune-filesystem-object-storage) scripts after the upgrade and migration script is highly recommended
* If you run PostgreSQL or Redis with TLS connection and self signed certificates, you must explicitly set `reject_unauthorized` to `true` or fill `ca`, `cert` and `key` settings in your [production.yaml](https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L84)
### Maintenance
+9 -9
View File
@@ -84,9 +84,9 @@ database:
ssl: false
ssl_settings:
reject_unauthorized: false
ca: '/absolute/path/to/server-certificates/root.crt'
cert: '/absolute/path/to/client-certificates/postgresql.crt'
key: '/absolute/path/to/client-key/postgresql.key'
ca: null # '/absolute/path/to/server-certificates/root.crt'
cert: null # '/absolute/path/to/client-certificates/postgresql.crt'
key: null # '/absolute/path/to/client-key/postgresql.key'
suffix: '_dev'
username: 'peertube'
password: 'peertube'
@@ -104,18 +104,18 @@ redis:
enable_tls: false
tls_settings:
reject_unauthorized: false
ca: '/absolute/path/to/server-certificates/root.crt'
cert: '/absolute/path/to/client-certificates/postgresql.crt'
key: '/absolute/path/to/client-key/postgresql.key'
ca: null # '/absolute/path/to/server-certificates/root.crt'
cert: null # '/absolute/path/to/client-certificates/redis.crt'
key: null # '/absolute/path/to/client-key/redis.key'
sentinel:
enabled: false
enable_tls: false
tls_settings:
reject_unauthorized: false
ca: '/absolute/path/to/server-certificates/root.crt'
cert: '/absolute/path/to/client-certificates/postgresql.crt'
key: '/absolute/path/to/client-key/postgresql.key'
ca: null # '/absolute/path/to/server-certificates/root.crt'
cert: null # '/absolute/path/to/client-certificates/redis.crt'
key: null # '/absolute/path/to/client-key/redis.key'
master_name: ''
password: ''
sentinels:
+9 -9
View File
@@ -82,9 +82,9 @@ database:
ssl: false
ssl_settings:
reject_unauthorized: false
ca: '/absolute/path/to/server-certificates/root.crt'
cert: '/absolute/path/to/client-certificates/postgresql.crt'
key: '/absolute/path/to/client-key/postgresql.key'
ca: null # '/absolute/path/to/server-certificates/root.crt'
cert: null # '/absolute/path/to/client-certificates/postgresql.crt'
key: null # '/absolute/path/to/client-key/postgresql.key'
suffix: '_prod'
username: 'peertube'
password: 'peertube'
@@ -102,18 +102,18 @@ redis:
enable_tls: false
tls_settings:
reject_unauthorized: false
ca: '/absolute/path/to/server-certificates/root.crt'
cert: '/absolute/path/to/client-certificates/postgresql.crt'
key: '/absolute/path/to/client-key/postgresql.key'
ca: null # '/absolute/path/to/server-certificates/root.crt'
cert: null # '/absolute/path/to/client-certificates/redis.crt'
key: null # '/absolute/path/to/client-key/redis.key'
sentinel:
enabled: false
enable_tls: false
tls_settings:
reject_unauthorized: false
ca: '/absolute/path/to/server-certificates/root.crt'
cert: '/absolute/path/to/client-certificates/postgresql.crt'
key: '/absolute/path/to/client-key/postgresql.key'
ca: null # '/absolute/path/to/server-certificates/root.crt'
cert: null # '/absolute/path/to/client-certificates/redis.crt'
key: null # '/absolute/path/to/client-key/redis.key'
master_name: ''
password: ''
sentinels:
+28 -3
View File
@@ -149,8 +149,13 @@ describe('Test videos API validator', function () {
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should fail with too many uuids', async function () {
const customQuery = { ...query, uuids: new Array(101).fill('dfd70b83-639f-4980-94af-304a56ab4b35') }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with valid uuids', async function () {
const customQuery = { ...query, uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] }
const customQuery = { ...query, uuids: new Array(90).fill('dfd70b83-639f-4980-94af-304a56ab4b35') }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 })
})
})
@@ -184,8 +189,18 @@ describe('Test videos API validator', function () {
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should fail with too many uuids', async function () {
const customQuery = { ...query, uuids: new Array(101).fill('dfd70b83-639f-4980-94af-304a56ab4b35') }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with the correct parameters', async function () {
await makeGetRequest({ url: server.url, path, query, expectedStatus: HttpStatusCode.OK_200 })
await makeGetRequest({
url: server.url,
path,
query: { ...query, uuids: new Array(90).fill('dfd70b83-639f-4980-94af-304a56ab4b35') },
expectedStatus: HttpStatusCode.OK_200
})
})
})
@@ -217,8 +232,18 @@ describe('Test videos API validator', function () {
await makeGetRequest({ url: server.url, path, query: { ...query, handles: [ '' ] }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should fail with too many handles', async function () {
const customQuery = { ...query, handles: new Array(101).fill('handle') }
await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with the correct parameters', async function () {
await makeGetRequest({ url: server.url, path, query, expectedStatus: HttpStatusCode.OK_200 })
await makeGetRequest({
url: server.url,
path,
query: { ...query, handles: new Array(90).fill('handle') },
expectedStatus: HttpStatusCode.OK_200
})
})
})
@@ -808,6 +808,30 @@ describe('Test video playlists API validator', function () {
})
})
it('Should succeed with many handles', async function () {
const handles = Array.from({ length: 90 }, (_, i) => i + 1)
await makeGetRequest({
url: server.url,
token: server.accessToken,
path,
query: { videoIds: handles },
expectedStatus: HttpStatusCode.OK_200
})
})
it('Should fail with too many handles', async function () {
const handles = Array.from({ length: 101 }, (_, i) => i + 1)
await makeGetRequest({
url: server.url,
token: server.accessToken,
path,
query: { videoIds: handles },
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed with the correct params', async function () {
await makeGetRequest({
url: server.url,
@@ -479,6 +479,20 @@ describe('Test multiple servers', function () {
let remoteVideosServer2 = []
let remoteVideosServer3 = []
let fileUrls: string[] = []
async function grabFileUrls (videoUUID: string) {
const video = await servers[0].videos.get({ id: videoUUID })
const { storyboards } = await servers[0].storyboard.list({ id: videoUUID })
return [
...video.files.map(f => f.fileUrl),
...video.files.map(f => f.torrentUrl),
...video.thumbnails.map(f => f.fileUrl),
...storyboards.map(s => s.fileUrl)
]
}
before(async function () {
{
const { data } = await servers[0].videos.list()
@@ -495,6 +509,8 @@ describe('Test multiple servers', function () {
localVideosServer3 = data.filter(video => video.isLocal === true).map(video => video.uuid)
remoteVideosServer3 = data.filter(video => video.isLocal === false).map(video => video.uuid)
}
fileUrls = await grabFileUrls(localVideosServer3[0])
})
it('Should view multiple videos on owned servers', async function () {
@@ -609,6 +625,12 @@ describe('Test multiple servers', function () {
}
}
})
it('Should not have updated torrent files/storyboards/thumbnails', async function () {
const newFileUrls = await grabFileUrls(localVideosServer3[0])
expect(newFileUrls).to.have.members(fileUrls)
})
})
describe('Should manipulate these videos', function () {
+5 -5
View File
@@ -6,11 +6,10 @@ import { readFile } from 'fs/promises'
import type { Instance as MagnetUriInstance } from 'magnet-uri'
import type { ParseTorrent } from 'parse-torrent'
import { basename, join } from 'path'
import type { Torrent } from 'webtorrent'
import WebTorrent from 'webtorrent'
import type { Torrent, Instance, WebTorrent } from 'webtorrent'
export async function checkWebTorrentWorks (magnetUri: string, pathMatch?: RegExp) {
let res: { webtorrent: WebTorrent.Instance, torrent: WebTorrent.Torrent }
let res: { webtorrent: Instance, torrent: Torrent }
try {
res = await webtorrentAdd(magnetUri)
@@ -56,13 +55,14 @@ export async function magnetUriEncode (data: MagnetUriInstance) {
// ---------------------------------------------------------------------------
async function webtorrentAdd (torrentId: string) {
const WebTorrent = (await import('webtorrent')).default
const WebTorrent: WebTorrent = (await import('webtorrent')).default
const webtorrent = new WebTorrent({
natUpnp: false,
natPmp: false,
utp: false,
lsd: false
lsd: false,
dht: false
} as any)
webtorrent.on('error', err => console.error('Error in webtorrent', err))
+3 -1
View File
@@ -38,9 +38,11 @@ sed -i 's/"version": "\([^"]\+\)"/"version": "\1-'"$nightly_version"'"/' ./packa
"$directory_name/LICENSE" "$directory_name/README.md" \
"$directory_name/packages/core-utils/dist/" "$directory_name/packages/core-utils/package.json" \
"$directory_name/packages/ffmpeg/dist/" "$directory_name/packages/ffmpeg/package.json" \
"$directory_name/packages/node-utils/dist/" "$directory_name/packages/node-utils/package.json" \
"$directory_name/packages/models/dist/" "$directory_name/packages/models/package.json" \
"$directory_name/packages/node-utils/dist/" "$directory_name/packages/node-utils/package.json" \
"$directory_name/packages/server-commands/dist/" "$directory_name/packages/server-commands/package.json" \
"$directory_name/packages/transcription/dist/" "$directory_name/packages/transcription/package.json" \
"$directory_name/packages/typescript-utils/dist/" "$directory_name/packages/typescript-utils/package.json" \
"$directory_name/client/dist/" \
"$directory_name/client/package.json" "$directory_name/config" \
"$directory_name/dist" "$directory_name/package.json" \
+3 -1
View File
@@ -85,9 +85,11 @@ find dist/ packages/core-utils/dist/ \
"$directory_name/LICENSE" "$directory_name/README.md" \
"$directory_name/packages/core-utils/dist/" "$directory_name/packages/core-utils/package.json" \
"$directory_name/packages/ffmpeg/dist/" "$directory_name/packages/ffmpeg/package.json" \
"$directory_name/packages/node-utils/dist/" "$directory_name/packages/node-utils/package.json" \
"$directory_name/packages/models/dist/" "$directory_name/packages/models/package.json" \
"$directory_name/packages/node-utils/dist/" "$directory_name/packages/node-utils/package.json" \
"$directory_name/packages/server-commands/dist/" "$directory_name/packages/server-commands/package.json" \
"$directory_name/packages/transcription/dist/" "$directory_name/packages/transcription/package.json" \
"$directory_name/packages/typescript-utils/dist/" "$directory_name/packages/typescript-utils/package.json" \
"$directory_name/client/dist/" \
"$directory_name/client/package.json" "$directory_name/config" \
"$directory_name/dist" "$directory_name/package.json" \
+2 -4
View File
@@ -1,12 +1,12 @@
import { VideoPlaylistForAccountListQuery } from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { scheduleActorRefreshIfNeeded } from '@server/lib/activitypub/actors/refresh.js'
import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import express from 'express'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js'
import { getFormattedObjects } from '../../helpers/utils.js'
import { JobQueue } from '../../lib/job-queue/index.js'
import { Hooks } from '../../lib/plugins/hooks.js'
import {
apiRateLimiter,
@@ -146,9 +146,7 @@ export {
function getAccount (req: express.Request, res: express.Response) {
const account = res.locals.account
if (account.isOutdated()) {
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'actor', url: account.Actor.url } })
}
scheduleActorRefreshIfNeeded(account.Actor)
return res.json(account.toFormattedJSON())
}
@@ -60,6 +60,7 @@ import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
import { VideoModel } from '../../../models/video/video.js'
import { channelCollaborators } from './video-channel-collaborators.js'
import { videoChannelLogosRouter } from './video-channel-logos.js'
import { scheduleActorRefreshIfNeeded } from '@server/lib/activitypub/actors/refresh.js'
const auditLogger = auditLoggerFactory('channels')
@@ -307,9 +308,7 @@ async function getVideoChannel (req: express.Request, res: express.Response) {
const id = res.locals.videoChannel.id
const videoChannel = await Hooks.wrapObject(res.locals.videoChannel, 'filter:api.video-channel.get.result', { id })
if (videoChannel.isOutdated()) {
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannel.Actor.url } })
}
scheduleActorRefreshIfNeeded(videoChannel.Actor)
return res.json(videoChannel.toFormattedJSON())
}
@@ -13,7 +13,7 @@ import {
VideoPlaylistUpdate
} from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists/index.js'
import { schedulePlaylistRefreshIfNeeded } from '@server/lib/activitypub/playlists/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import {
generateThumbnailForPlaylist,
@@ -170,7 +170,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response)
function getVideoPlaylist (req: express.Request, res: express.Response) {
const videoPlaylist = res.locals.videoPlaylistSummary
scheduleRefreshIfNeeded(videoPlaylist)
schedulePlaylistRefreshIfNeeded(videoPlaylist)
return res.json(videoPlaylist.toFormattedJSON())
}
+2 -4
View File
@@ -1,5 +1,6 @@
import { HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { scheduleVideoRefreshIfNeeded } from '@server/lib/activitypub/videos/index.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
@@ -10,7 +11,6 @@ import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import {
apiRateLimiter,
@@ -142,9 +142,7 @@ async function getVideo (req: express.Request, res: express.Response) {
// Filter may return null/undefined value to forbid video access
if (!video) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
if (video.isOutdated()) {
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
}
scheduleVideoRefreshIfNeeded(video)
return res.json(video.toFormattedDetailsJSON())
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoChannelActivityAction, VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { CreateJobTypeAndPayload, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { regenerateLocalVideoThumbnailsFromVideoIfNeeded } from '@server/lib/thumbnail.js'
import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
@@ -178,7 +178,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
}
async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
const jobs: (CreateJobTypeAndPayload & CreateJobOptions)[] = [
{
type: 'manage-video-torrent' as const,
payload: {
@@ -46,6 +46,9 @@ export async function getVideosForFeeds (options: {
export function getCommonVideoFeedAttributes (video: VideoModel) {
const localLink = WEBSERVER.URL + video.getWatchStaticPath()
let thumbnails = video.filterThumbnails('1:1')
if (thumbnails.length === 0) thumbnails = video.filterThumbnails('16:9')
return {
title: video.name,
link: localLink,
@@ -59,7 +62,7 @@ export function getCommonVideoFeedAttributes (video: VideoModel) {
? [ { name: getCategoryLabel(video.category) } ]
: undefined,
thumbnails: video.filterThumbnails('1:1').map(t => ({
thumbnails: thumbnails.map(t => ({
url: WEBSERVER.URL + t.getFileStaticPath(),
width: t.width,
height: t.height
+16 -2
View File
@@ -3,6 +3,7 @@ import { UploadFilesForCheck } from 'express'
import 'multer'
import { sep } from 'path'
import validator from 'validator'
import { logger } from '../logger.js'
export function exists (value: any) {
return value !== undefined && value !== null
@@ -165,11 +166,24 @@ export function toValueOrNull (value: string) {
return value
}
export function toArray (value: any) {
if (!value) return []
if (isArray(value)) return value
if (value?.[0]) return Object.values(value)
return [ value ]
}
export function toIntArray (value: any) {
if (!value) return []
if (isArray(value) === false) return [ validator.default.toInt(value) ]
if (isArray(value)) return value.map(v => validator.default.toInt(v))
return value.map(v => validator.default.toInt(v))
if (value?.[0]) return Object.values(value).map(v => validator.default.toInt(v + ''))
if (typeof value === 'string' || typeof value === 'number') return [ validator.default.toInt(value + '') ]
return []
}
// ---------------------------------------------------------------------------
+13 -4
View File
@@ -2,9 +2,10 @@ import { HttpStatusCode } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CachePromiseFactory } from '@server/helpers/promise-cache.js'
import { PeerTubeRequestError } from '@server/helpers/requests.js'
import { JobQueue } from '@server/lib/job-queue/job-queue.js'
import { ActorLoadByUrlType } from '@server/lib/model-loaders/index.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { MActorFull, MActorOutdated } from '@server/types/models/index.js'
import { MActorFull, MActorOutdated, MActorUrl } from '@server/types/models/index.js'
import { fetchRemoteActor } from './shared/index.js'
import { APActorUpdater } from './updater.js'
import { getUrlFromWebfinger } from './webfinger.js'
@@ -16,20 +17,28 @@ type RefreshOptions<T> = {
fetchedType: Extract<ActorLoadByUrlType, 'all'> | 'partial'
}
// ---------------------------------------------------------------------------
const promiseCache = new CachePromiseFactory(
doRefresh,
(options: RefreshOptions<MActorFull | MActorOutdated>) => options.actor.id + ''
)
function refreshActorIfNeeded<T extends MActorFull | MActorOutdated> (options: RefreshOptions<T>): RefreshResult<T> {
export function refreshActorIfNeeded<T extends MActorFull | MActorOutdated> (options: RefreshOptions<T>): RefreshResult<T> {
const actorArg = options.actor
if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false })
return promiseCache.run(options)
}
export {
refreshActorIfNeeded
export function scheduleActorRefreshIfNeeded (actor: MActorOutdated & MActorUrl) {
if (!actor.isOutdated()) return
JobQueue.Instance.createJobAsync({
type: 'activitypub-refresher',
payload: { type: 'actor', url: actor.url },
deduplicationId: `refresh-actor-${actor.url}`
})
}
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -2,14 +2,14 @@ import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoPlaylistFullSummary } from '@server/types/models/index.js'
import { getAPId } from '../activity.js'
import { createOrUpdateVideoPlaylist } from './create-update.js'
import { scheduleRefreshIfNeeded } from './refresh.js'
import { schedulePlaylistRefreshIfNeeded } from './refresh.js'
import { fetchRemoteVideoPlaylist } from './shared/index.js'
export async function getOrCreateAPVideoPlaylist (playlistUrl: string): Promise<MVideoPlaylistFullSummary> {
const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
if (playlistFromDatabase) {
scheduleRefreshIfNeeded(playlistFromDatabase)
schedulePlaylistRefreshIfNeeded(playlistFromDatabase)
return playlistFromDatabase
}
@@ -6,10 +6,14 @@ import { MVideoPlaylist, MVideoPlaylistOwnerDefault } from '@server/types/models
import { createOrUpdateVideoPlaylist } from './create-update.js'
import { fetchRemoteVideoPlaylist } from './shared/index.js'
function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
function schedulePlaylistRefreshIfNeeded (playlist: MVideoPlaylist) {
if (!playlist.isOutdated()) return
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
JobQueue.Instance.createJobAsync({
type: 'activitypub-refresher',
payload: { type: 'video-playlist', url: playlist.url },
deduplicationId: `refresh-video-playlist-${playlist.url}`
})
}
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwnerDefault): Promise<MVideoPlaylistOwnerDefault> {
@@ -51,5 +55,5 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwnerD
export {
refreshVideoPlaylistIfNeeded,
scheduleRefreshIfNeeded
schedulePlaylistRefreshIfNeeded
}
+17 -33
View File
@@ -1,8 +1,7 @@
import { APObjectId } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { JobQueue } from '@server/lib/job-queue/index.js'
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
import { loadVideoByUrl } from '@server/lib/model-loaders/index.js'
import {
MVideoAccountLightBlacklistAllFiles,
MVideoImmutable,
@@ -10,10 +9,10 @@ import {
MVideoThumbnailBlacklist
} from '@server/types/models/index.js'
import { getAPId } from '../activity.js'
import { refreshVideoIfNeeded } from './refresh.js'
import { refreshVideoIfNeeded, scheduleVideoRefreshIfNeeded } from './refresh.js'
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
type GetVideoResult <T> = Promise<{
type GetVideoResult<T> = Promise<{
video: T
created: boolean
autoBlacklisted?: boolean
@@ -55,12 +54,22 @@ export async function getOrCreateAPVideo (
// Get video url
const videoUrl = getAPId(options.videoObject)
let videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
const videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
if (videoFromDatabase) {
if (allowRefresh === true) {
// Typings ensure allowRefresh === false in unsafe-only-immutable-attributes fetch type
videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam)
// We know that allowRefresh === false on `unsafe-only-immutable-attributes` fetch type because of type definitions
let video = videoFromDatabase as MVideoThumbnail
if (allowRefresh === true && video.isOutdated()) {
if (syncParam.refreshVideo === true) {
video = await refreshVideoIfNeeded({
video,
fetchedType: fetchType,
syncParam
})
} else {
scheduleVideoRefreshIfNeeded(video)
}
}
return { video: videoFromDatabase, created: false }
@@ -107,28 +116,3 @@ export async function maybeGetOrCreateAPVideo (options: GetVideoParamAll | GetVi
return { video: undefined, created: false }
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function scheduleRefresh (video: MVideoThumbnail, fetchType: VideoLoadByUrlType, syncParam: SyncParam) {
if (!video.isOutdated()) return video
const refreshOptions = {
video,
fetchedType: fetchType,
syncParam
}
if (syncParam.refreshVideo === true) {
return refreshVideoIfNeeded(refreshOptions)
}
await JobQueue.Instance.createJob({
type: 'activitypub-refresher',
payload: { type: 'video', url: video.url }
})
return video
}
+15 -10
View File
@@ -1,14 +1,25 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { PeerTubeRequestError } from '@server/helpers/requests.js'
import { JobQueue } from '@server/lib/job-queue/job-queue.js'
import { VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoAccountLightBlacklistAllFiles, MVideoThumbnail } from '@server/types/models/index.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import { MVideo, MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
import { ActorFollowHealthCache } from '../../actor-follow-health-cache.js'
import { fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
import { APVideoUpdater } from './updater.js'
async function refreshVideoIfNeeded (options: {
export function scheduleVideoRefreshIfNeeded (video: MVideo) {
if (!video.isOutdated()) return
JobQueue.Instance.createJobAsync({
type: 'activitypub-refresher',
deduplicationId: `video-refresh-${video.url}`,
payload: { type: 'video', url: video.url }
})
}
export async function refreshVideoIfNeeded (options: {
video: MVideoThumbnail
fetchedType: VideoLoadByUrlType
syncParam: SyncParam
@@ -17,7 +28,7 @@ async function refreshVideoIfNeeded (options: {
// We need more attributes if the argument video was fetched with not enough joints
const video = options.fetchedType === 'all'
? options.video as MVideoAccountLightBlacklistAllFiles
? options.video as MVideoFullLight
: await VideoModel.loadByUrlAndPopulateAccountAndFiles(options.video.url)
const lTags = loggerTagsFactory('ap', 'video', 'refresh', video.uuid, video.url)
@@ -62,9 +73,3 @@ async function refreshVideoIfNeeded (options: {
return video
}
}
// ---------------------------------------------------------------------------
export {
refreshVideoIfNeeded
}
@@ -110,13 +110,16 @@ export abstract class APVideoAbstractBuilder {
}
protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) {
const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t)
if (existingStoryboard?.fileUrl === storyboardAttributes?.fileUrl) return
if (existingStoryboard) await existingStoryboard.destroy({ transaction: t })
const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
if (!storyboardAttributes) return
return StoryboardModel.create(storyboardAttributes, { transaction: t })
if (storyboardAttributes) {
await StoryboardModel.create(storyboardAttributes, { transaction: t })
}
}
protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
@@ -134,11 +137,12 @@ export abstract class APVideoAbstractBuilder {
}
protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
const oldFiles = video.VideoFiles || []
const newVideoFiles = getFileAttributesFromUrl(video, this.videoObject.url, oldFiles).map(a => new VideoFileModel(a))
// Remove video files that do not exist anymore
await deleteAllModels(filterNonExistingModels(video.VideoFiles || [], newVideoFiles), t)
await deleteAllModels(filterNonExistingModels(oldFiles, newVideoFiles), t)
// Update or add other one
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
@@ -217,7 +221,11 @@ export abstract class APVideoAbstractBuilder {
) {
const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type)
const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(
playlistModel,
tagObjects,
oldStreamingPlaylistFiles
).map(a => new VideoFileModel(a))
await deleteAllModels(filterNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles), t)
@@ -54,54 +54,57 @@ export function getTagsFromObject (videoObject: VideoObject) {
export function getFileAttributesFromUrl (
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
urls: (ActivityTagObject | ActivityUrlObject)[]
urls: (ActivityTagObject | ActivityUrlObject)[],
oldFiles: MVideoFile[]
) {
const fileUrls = urls.filter(u => isAPVideoUrlObject(u))
if (fileUrls.length === 0) return []
const fileUrlObjects = urls.filter(u => isAPVideoUrlObject(u))
if (fileUrlObjects.length === 0) return []
const attributes: FilteredModelAttributes<VideoFileModel>[] = []
for (const fileUrl of fileUrls) {
for (const fileUrlObject of fileUrlObjects) {
// Fetch associated metadata url, if any
const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
.find(u => {
return u.height === fileUrl.height &&
u.fps === fileUrl.fps &&
u.rel.includes(fileUrl.mediaType)
return u.height === fileUrlObject.height &&
u.fps === fileUrlObject.fps &&
u.rel.includes(fileUrlObject.mediaType)
})
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
const resolution = fileUrl.height
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrlObject.mediaType)
const resolution = fileUrlObject.height
const [ videoId, videoStreamingPlaylistId ] = isStreamingPlaylist(videoOrPlaylist)
? [ null, videoOrPlaylist.id ]
: [ videoOrPlaylist.id, null ]
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl })
const fileUrl = fileUrlObject.href
const existingFile = oldFiles.find(f => f.fileUrl === fileUrl)
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrlObject, existingFile })
const attribute: Partial<AttributesOnly<MVideoFile>> = {
extname,
resolution,
size: fileUrl.size,
fps: exists(fileUrl.fps) && fileUrl.fps >= 0
? fileUrl.fps
size: fileUrlObject.size,
fps: exists(fileUrlObject.fps) && fileUrlObject.fps >= 0
? fileUrlObject.fps
: -1,
metadataUrl: metadata?.href,
width: fileUrl.width,
height: fileUrl.height,
width: fileUrlObject.width,
height: fileUrlObject.height,
// Use the name of the remote file because we don't proxify video file requests
filename: basename(fileUrl.href),
fileUrl: fileUrl.href,
filename: basename(fileUrl),
fileUrl,
infoHash,
torrentFilename,
torrentUrl,
formatFlags: buildFileFormatFlags(fileUrl, isStreamingPlaylist(videoOrPlaylist)),
streams: buildFileStreams(fileUrl, resolution),
formatFlags: buildFileFormatFlags(fileUrlObject, isStreamingPlaylist(videoOrPlaylist)),
streams: buildFileStreams(fileUrlObject, resolution),
// This is a video file owned by a video or by a streaming playlist
videoId,
@@ -349,13 +352,14 @@ function isAPSensitiveTagObject (tag: any): tag is ActivitySensitiveTagObject {
function getTorrentRelatedInfo (options: {
videoOrPlaylist: MVideo | MStreamingPlaylistVideo
urls: (ActivityTagObject | ActivityUrlObject)[]
fileUrl: ActivityVideoUrlObject
fileUrlObject: ActivityVideoUrlObject
existingFile?: MVideoFile
}) {
const { urls, fileUrl, videoOrPlaylist } = options
const { urls, fileUrlObject, videoOrPlaylist, existingFile } = options
// Fetch associated magnet uri
const magnet = urls.filter(isAPMagnetUrlObject)
.find(u => u.height === fileUrl.height)
.find(u => u.height === fileUrlObject.height)
if (!magnet) {
return {
@@ -378,7 +382,7 @@ function getTorrentRelatedInfo (options: {
torrentUrl,
// Use our own torrent name since we proxify torrent requests
torrentFilename: generateTorrentFileName(videoOrPlaylist, fileUrl.height),
torrentFilename: existingFile?.torrentFilename ?? generateTorrentFileName(videoOrPlaylist, fileUrlObject.height),
infoHash: magnetParsed.infoHash
}
@@ -2,7 +2,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoCaption } from '@server/types/models/index.js'
import { JobQueue } from '../job-queue/job-queue.js'
import { scheduleVideoRefreshIfNeeded } from '../activitypub/videos/refresh.js'
import { AbstractFileCache } from './shared/abstract-file-cache.js'
const lTags = loggerTagsFactory('lazy-load', 'video-captions')
@@ -24,7 +24,7 @@ export class VideoCaptionsFileCache extends AbstractFileCache<MVideoCaption> {
try {
const video = await VideoModel.load(model.videoId)
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
scheduleVideoRefreshIfNeeded(video)
} catch (err) {
logger.error('Error while refreshing video for lazy fetch', { ...lTags(), err })
}
@@ -2,7 +2,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { StoryboardModel } from '@server/models/video/storyboard.js'
import { VideoModel } from '@server/models/video/video.js'
import { MStoryboard } from '@server/types/models/index.js'
import { JobQueue } from '../job-queue/job-queue.js'
import { scheduleVideoRefreshIfNeeded } from '../activitypub/videos/index.js'
import { AbstractImageFileCache } from './shared/abstract-image-file-cache.js'
const lTags = loggerTagsFactory('lazy-load', 'video-storyboards')
@@ -24,7 +24,7 @@ export class VideoStoryboardsImageFileCache extends AbstractImageFileCache<MStor
try {
const video = await VideoModel.load(model.videoId)
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
scheduleVideoRefreshIfNeeded(video)
} catch (err) {
logger.error('Error while refreshing video for lazy fetch', { ...lTags(), err })
}
@@ -2,7 +2,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
import { VideoModel } from '@server/models/video/video.js'
import { MThumbnail } from '@server/types/models/index.js'
import { JobQueue } from '../job-queue/job-queue.js'
import { scheduleVideoRefreshIfNeeded } from '../activitypub/videos/refresh.js'
import { AbstractImageFileCache } from './shared/abstract-image-file-cache.js'
const lTags = loggerTagsFactory('lazy-load', 'video-thumbnails')
@@ -24,7 +24,7 @@ export class VideoThumbnailsImageFileCache extends AbstractImageFileCache<MThumb
try {
const video = await VideoModel.load(model.videoId)
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
scheduleVideoRefreshIfNeeded(video)
} catch (err) {
logger.error('Error while refreshing video for lazy fetch', { ...lTags(), err })
}
+13 -6
View File
@@ -76,7 +76,7 @@ import { processVideoTranscoding } from './handlers/video-transcoding.js'
import { processVideoTranscription } from './handlers/video-transcription.js'
import { processVideosStats } from './handlers/video-stats.js'
export type CreateJobArgument =
export type CreateJobTypeAndPayload =
| { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload }
| { type: 'activitypub-http-broadcast-parallel', payload: ActivitypubHttpBroadcastPayload }
| { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload }
@@ -110,6 +110,7 @@ export type CreateJobOptions = {
delay?: number
priority?: number
failParentOnFailure?: boolean
deduplicationId?: string
}
const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
@@ -356,12 +357,12 @@ class JobQueue {
// ---------------------------------------------------------------------------
createJobAsync (options: CreateJobArgument & CreateJobOptions): void {
createJobAsync (options: CreateJobTypeAndPayload & CreateJobOptions): void {
this.createJob(options)
.catch(err => logger.error('Cannot create job.', { err, options }))
}
createJob (options: CreateJobArgument & CreateJobOptions | undefined) {
createJob (options: CreateJobTypeAndPayload & CreateJobOptions | undefined) {
if (!options) return
const queue: Queue = this.queues[options.type]
@@ -375,7 +376,7 @@ class JobQueue {
return queue.add('job', options.payload, jobOptions)
}
createSequentialJobFlow (...jobs: ((CreateJobArgument & CreateJobOptions) | undefined)[]) {
createSequentialJobFlow (...jobs: ((CreateJobTypeAndPayload & CreateJobOptions) | undefined)[]) {
let lastJob: FlowJob
logger.debug('Creating jobs in local job queue', { jobs })
@@ -395,7 +396,7 @@ class JobQueue {
return this.flowProducer.add(lastJob)
}
createJobWithChildren (parent: CreateJobArgument & CreateJobOptions, children: (CreateJobArgument & CreateJobOptions)[]) {
createJobWithChildren (parent: CreateJobTypeAndPayload & CreateJobOptions, children: (CreateJobTypeAndPayload & CreateJobOptions)[]) {
return this.flowProducer.add({
...this.buildJobFlowOption(parent),
@@ -403,7 +404,7 @@ class JobQueue {
})
}
private buildJobFlowOption (job: CreateJobArgument & CreateJobOptions): FlowJob {
private buildJobFlowOption (job: CreateJobTypeAndPayload & CreateJobOptions): FlowJob {
return {
name: 'job',
data: job.payload,
@@ -423,6 +424,12 @@ class JobQueue {
priority: options.priority,
delay: options.delay,
deduplication: options.deduplicationId
? {
id: options.deduplicationId
}
: undefined,
...this.buildJobRemovalOptions(type)
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { buildYoutubeDLImport } from '@server/lib/video-pre-import.js'
import { UserModel } from '@server/models/user/user.js'
import { VideoImportModel } from '@server/models/video/video-import.js'
import { MChannelAccountDefault, MChannelSync } from '@server/types/models/index.js'
import { CreateJobArgument, JobQueue } from './job-queue/index.js'
import { CreateJobTypeAndPayload, JobQueue } from './job-queue/index.js'
import { ServerConfigManager } from './server-config-manager.js'
import { buildRetryImportJob } from './video-post-import.js'
import { getLeastPrivatePrivacy } from './video.js'
@@ -49,7 +49,7 @@ export async function synchronizeChannel (options: {
{ targetUrls, ...lTags() }
)
const children: CreateJobArgument[] = []
const children: CreateJobTypeAndPayload[] = []
let buildJobErrors = 0
@@ -108,7 +108,7 @@ export async function synchronizeChannel (options: {
}
// Will update the channel sync status
const parent: CreateJobArgument = {
const parent: CreateJobTypeAndPayload = {
type: 'after-video-channel-import',
payload: {
channelSyncId: channelSync?.id,
@@ -7,7 +7,7 @@ import {
VideoFileStreamType,
VideoTranscodingPayload
} from '@peertube/peertube-models'
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js'
import { CreateJobTypeAndPayload, JobQueue } from '@server/lib/job-queue/index.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MUserId, MVideo } from '@server/types/models/index.js'
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
@@ -47,7 +47,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder<FullPayload>
})
})
const transcodingJobBuilderJob: CreateJobArgument = {
const transcodingJobBuilderJob: CreateJobTypeAndPayload = {
type: 'transcoding-job-builder',
payload: {
videoUUID: video.uuid,
+3 -3
View File
@@ -10,7 +10,7 @@ import { CONFIG } from '@server/initializers/config.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideo, MVideoFile, MVideoFullLight, MVideoUUID } from '@server/types/models/index.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from './job-queue/job-queue.js'
import { CreateJobTypeAndPayload, CreateJobOptions, JobQueue } from './job-queue/job-queue.js'
import { VideoStoryboardJobHandler } from './runners/index.js'
import { createTranscriptionTaskIfNeeded } from './video-captions.js'
import { moveFilesIfPrivacyChanged } from './video-privacy.js'
@@ -106,7 +106,7 @@ export async function addVideoJobsAfterCreation (options: {
}) {
const { video, videoFile, generateTranscription } = options
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
const jobs: (CreateJobTypeAndPayload & CreateJobOptions)[] = [
{
type: 'manage-video-torrent' as 'manage-video-torrent',
payload: {
@@ -178,7 +178,7 @@ export async function addVideoJobsAfterUpdate (options: {
oldPrivacy: VideoPrivacyType
}) {
const { video, nameChanged, oldPrivacy, isNewVideoForFederation } = options
const jobs: CreateJobArgument[] = []
const jobs: CreateJobTypeAndPayload[] = []
const filePathChanged = await moveFilesIfPrivacyChanged(video, oldPrivacy)
const hls = video.getHLSPlaylist()
+11 -8
View File
@@ -1,12 +1,13 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { HttpStatusCode, ServerFollowCreate } from '@peertube/peertube-models'
import { isProdInstance } from '@peertube/peertube-node-utils'
import { isEachUniqueHandleValid, isFollowStateValid, isRemoteHandleValid } from '@server/helpers/custom-validators/follows.js'
import { hasArrayLength, toArray } from '@server/helpers/custom-validators/misc.js'
import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors/index.js'
import { getRemoteNameAndHost } from '@server/lib/activitypub/follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { MActorFollowActorsDefault } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor.js'
import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers.js'
import { logger } from '../../helpers/logger.js'
@@ -32,11 +33,13 @@ const listFollowsValidator = [
const followValidator = [
body('hosts')
.toArray()
.customSanitizer(toArray)
.custom(v => hasArrayLength(v, { max: 100 }))
.custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'),
body('handles')
.toArray()
.customSanitizer(toArray)
.custom(v => hasArrayLength(v, { max: 100 }))
.custom(isEachUniqueHandleValid).withMessage('Should have an array of handles'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -147,10 +150,10 @@ const rejectFollowerValidator = [
// ---------------------------------------------------------------------------
export {
followValidator,
removeFollowingValidator,
getFollowerValidator,
acceptFollowerValidator,
followValidator,
getFollowerValidator,
listFollowsValidator,
rejectFollowerValidator,
listFollowsValidator
removeFollowingValidator
}
+18 -8
View File
@@ -1,8 +1,15 @@
import express from 'express'
import { query } from 'express-validator'
import { isSearchTargetValid } from '@server/helpers/custom-validators/search.js'
import { isHostValid } from '@server/helpers/custom-validators/servers.js'
import { areUUIDsValid, isDateValid, isNotEmptyStringArray, toCompleteUUIDs } from '../../helpers/custom-validators/misc.js'
import express from 'express'
import { query } from 'express-validator'
import {
areUUIDsValid,
hasArrayLength,
isDateValid,
isNotEmptyStringArray,
toArray,
toCompleteUUIDs
} from '../../helpers/custom-validators/misc.js'
import { areValidationErrors } from './shared/index.js'
const videosSearchValidator = [
@@ -33,7 +40,8 @@ const videosSearchValidator = [
query('uuids')
.optional()
.toArray()
.customSanitizer(toArray)
.custom(v => hasArrayLength(v, { max: 100 }))
.customSanitizer(toCompleteUUIDs)
.custom(areUUIDsValid).withMessage('Should have valid array of uuid'),
@@ -63,7 +71,8 @@ const videoChannelsListSearchValidator = [
query('handles')
.optional()
.toArray()
.customSanitizer(toArray)
.custom(v => hasArrayLength(v, { max: 100 }))
.custom(isNotEmptyStringArray).withMessage('Should have valid array of handles'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -88,7 +97,8 @@ const videoPlaylistsListSearchValidator = [
query('uuids')
.optional()
.toArray()
.customSanitizer(toArray)
.custom(v => hasArrayLength(v, { max: 100 }))
.customSanitizer(toCompleteUUIDs)
.custom(areUUIDsValid).withMessage('Should have valid array of uuid'),
@@ -102,7 +112,7 @@ const videoPlaylistsListSearchValidator = [
// ---------------------------------------------------------------------------
export {
videosSearchValidator,
videoChannelsListSearchValidator,
videoPlaylistsListSearchValidator
videoPlaylistsListSearchValidator,
videosSearchValidator
}
@@ -16,6 +16,7 @@ import { MUserAccountId } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import {
hasArrayLength,
isArrayOf,
isIdOrUUIDValid,
isIdValid,
@@ -465,6 +466,7 @@ export const commonVideoPlaylistFiltersValidator = [
export const doVideosInPlaylistExistValidator = [
query('videoIds')
.customSanitizer(toIntArray)
.custom(v => hasArrayLength(v, { max: 100 }))
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {