Implement replace file in server side
This commit is contained in:
parent
c6867725fb
commit
12dc3a942a
|
@ -595,6 +595,11 @@ video_studio:
|
||||||
remote_runners:
|
remote_runners:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_file:
|
||||||
|
update:
|
||||||
|
# Add ability for users to replace the video file of an existing video
|
||||||
|
enabled: false
|
||||||
|
|
||||||
import:
|
import:
|
||||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||||
videos:
|
videos:
|
||||||
|
|
|
@ -605,6 +605,11 @@ video_studio:
|
||||||
remote_runners:
|
remote_runners:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_file:
|
||||||
|
update:
|
||||||
|
# Add ability for users to replace the video file of an existing video
|
||||||
|
enabled: false
|
||||||
|
|
||||||
import:
|
import:
|
||||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||||
videos:
|
videos:
|
||||||
|
|
|
@ -284,6 +284,11 @@ function customConfig (): CustomConfig {
|
||||||
enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
|
enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
|
||||||
|
}
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
|
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
|
||||||
|
|
|
@ -26,7 +26,6 @@ import {
|
||||||
setDefaultVideosSort,
|
setDefaultVideosSort,
|
||||||
videosCustomGetValidator,
|
videosCustomGetValidator,
|
||||||
videosGetValidator,
|
videosGetValidator,
|
||||||
videoSourceGetValidator,
|
|
||||||
videosRemoveValidator,
|
videosRemoveValidator,
|
||||||
videosSortValidator
|
videosSortValidator
|
||||||
} from '../../../middlewares'
|
} from '../../../middlewares'
|
||||||
|
@ -39,7 +38,9 @@ import { filesRouter } from './files'
|
||||||
import { videoImportsRouter } from './import'
|
import { videoImportsRouter } from './import'
|
||||||
import { liveRouter } from './live'
|
import { liveRouter } from './live'
|
||||||
import { ownershipVideoRouter } from './ownership'
|
import { ownershipVideoRouter } from './ownership'
|
||||||
|
import { videoPasswordRouter } from './passwords'
|
||||||
import { rateVideoRouter } from './rate'
|
import { rateVideoRouter } from './rate'
|
||||||
|
import { videoSourceRouter } from './source'
|
||||||
import { statsRouter } from './stats'
|
import { statsRouter } from './stats'
|
||||||
import { storyboardRouter } from './storyboard'
|
import { storyboardRouter } from './storyboard'
|
||||||
import { studioRouter } from './studio'
|
import { studioRouter } from './studio'
|
||||||
|
@ -48,7 +49,6 @@ import { transcodingRouter } from './transcoding'
|
||||||
import { updateRouter } from './update'
|
import { updateRouter } from './update'
|
||||||
import { uploadRouter } from './upload'
|
import { uploadRouter } from './upload'
|
||||||
import { viewRouter } from './view'
|
import { viewRouter } from './view'
|
||||||
import { videoPasswordRouter } from './passwords'
|
|
||||||
|
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
const videosRouter = express.Router()
|
const videosRouter = express.Router()
|
||||||
|
@ -72,6 +72,7 @@ videosRouter.use('/', transcodingRouter)
|
||||||
videosRouter.use('/', tokenRouter)
|
videosRouter.use('/', tokenRouter)
|
||||||
videosRouter.use('/', videoPasswordRouter)
|
videosRouter.use('/', videoPasswordRouter)
|
||||||
videosRouter.use('/', storyboardRouter)
|
videosRouter.use('/', storyboardRouter)
|
||||||
|
videosRouter.use('/', videoSourceRouter)
|
||||||
|
|
||||||
videosRouter.get('/categories',
|
videosRouter.get('/categories',
|
||||||
openapiOperationDoc({ operationId: 'getCategories' }),
|
openapiOperationDoc({ operationId: 'getCategories' }),
|
||||||
|
@ -108,13 +109,6 @@ videosRouter.get('/:id/description',
|
||||||
asyncMiddleware(getVideoDescription)
|
asyncMiddleware(getVideoDescription)
|
||||||
)
|
)
|
||||||
|
|
||||||
videosRouter.get('/:id/source',
|
|
||||||
openapiOperationDoc({ operationId: 'getVideoSource' }),
|
|
||||||
authenticate,
|
|
||||||
asyncMiddleware(videoSourceGetValidator),
|
|
||||||
getVideoSource
|
|
||||||
)
|
|
||||||
|
|
||||||
videosRouter.get('/:id',
|
videosRouter.get('/:id',
|
||||||
openapiOperationDoc({ operationId: 'getVideo' }),
|
openapiOperationDoc({ operationId: 'getVideo' }),
|
||||||
optionalAuthenticate,
|
optionalAuthenticate,
|
||||||
|
@ -177,10 +171,6 @@ async function getVideoDescription (req: express.Request, res: express.Response)
|
||||||
return res.json({ description })
|
return res.json({ description })
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVideoSource (req: express.Request, res: express.Response) {
|
|
||||||
return res.json(res.locals.videoSource.toFormattedJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listVideos (req: express.Request, res: express.Response) {
|
async function listVideos (req: express.Request, res: express.Response) {
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,206 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { move } from 'fs-extra'
|
||||||
|
import { sequelizeTypescript } from '@server/initializers/database'
|
||||||
|
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue'
|
||||||
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
|
import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail'
|
||||||
|
import { uploadx } from '@server/lib/uploadx'
|
||||||
|
import { buildMoveToObjectStorageJob } from '@server/lib/video'
|
||||||
|
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
|
||||||
|
import { buildNewFile } from '@server/lib/video-file'
|
||||||
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
|
import { buildNextVideoState } from '@server/lib/video-state'
|
||||||
|
import { openapiOperationDoc } from '@server/middlewares/doc'
|
||||||
|
import { VideoModel } from '@server/models/video/video'
|
||||||
|
import { VideoSourceModel } from '@server/models/video/video-source'
|
||||||
|
import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||||
|
import { HttpStatusCode, VideoState } from '@shared/models'
|
||||||
|
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||||
|
import {
|
||||||
|
asyncMiddleware,
|
||||||
|
authenticate,
|
||||||
|
replaceVideoSourceResumableInitValidator,
|
||||||
|
replaceVideoSourceResumableValidator,
|
||||||
|
videoSourceGetLatestValidator
|
||||||
|
} from '../../../middlewares'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('api', 'video')
|
||||||
|
|
||||||
|
const videoSourceRouter = express.Router()
|
||||||
|
|
||||||
|
videoSourceRouter.get('/:id/source',
|
||||||
|
openapiOperationDoc({ operationId: 'getVideoSource' }),
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(videoSourceGetLatestValidator),
|
||||||
|
getVideoLatestSource
|
||||||
|
)
|
||||||
|
|
||||||
|
videoSourceRouter.post('/:id/source/replace-resumable',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(replaceVideoSourceResumableInitValidator),
|
||||||
|
(req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
|
||||||
|
)
|
||||||
|
|
||||||
|
videoSourceRouter.delete('/:id/source/replace-resumable',
|
||||||
|
authenticate,
|
||||||
|
(req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
|
||||||
|
)
|
||||||
|
|
||||||
|
videoSourceRouter.put('/:id/source/replace-resumable',
|
||||||
|
authenticate,
|
||||||
|
uploadx.upload, // uploadx doesn't next() before the file upload completes
|
||||||
|
asyncMiddleware(replaceVideoSourceResumableValidator),
|
||||||
|
asyncMiddleware(replaceVideoSourceResumable)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
videoSourceRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getVideoLatestSource (req: express.Request, res: express.Response) {
|
||||||
|
return res.json(res.locals.videoSource.toFormattedJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceVideoSourceResumable (req: express.Request, res: express.Response) {
|
||||||
|
const videoPhysicalFile = res.locals.updateVideoFileResumable
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
|
||||||
|
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
|
||||||
|
const originalFilename = videoPhysicalFile.originalname
|
||||||
|
|
||||||
|
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(res.locals.videoAll, videoFile)
|
||||||
|
await move(videoPhysicalFile.path, destination)
|
||||||
|
|
||||||
|
let oldWebVideoFiles: MVideoFile[] = []
|
||||||
|
let oldStreamingPlaylists: MStreamingPlaylistFiles[] = []
|
||||||
|
|
||||||
|
const inputFileUpdatedAt = new Date()
|
||||||
|
|
||||||
|
const video = await sequelizeTypescript.transaction(async transaction => {
|
||||||
|
const video = await VideoModel.loadFull(res.locals.videoAll.id, transaction)
|
||||||
|
|
||||||
|
oldWebVideoFiles = video.VideoFiles
|
||||||
|
oldStreamingPlaylists = video.VideoStreamingPlaylists
|
||||||
|
|
||||||
|
for (const file of video.VideoFiles) {
|
||||||
|
await file.destroy({ transaction })
|
||||||
|
}
|
||||||
|
for (const playlist of oldStreamingPlaylists) {
|
||||||
|
await playlist.destroy({ transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
videoFile.videoId = video.id
|
||||||
|
await videoFile.save({ transaction })
|
||||||
|
|
||||||
|
video.VideoFiles = [ videoFile ]
|
||||||
|
video.VideoStreamingPlaylists = []
|
||||||
|
|
||||||
|
video.state = buildNextVideoState()
|
||||||
|
video.duration = videoPhysicalFile.duration
|
||||||
|
video.inputFileUpdatedAt = inputFileUpdatedAt
|
||||||
|
await video.save({ transaction })
|
||||||
|
|
||||||
|
await autoBlacklistVideoIfNeeded({
|
||||||
|
video,
|
||||||
|
user,
|
||||||
|
isRemote: false,
|
||||||
|
isNew: false,
|
||||||
|
isNewFile: true,
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
|
||||||
|
return video
|
||||||
|
})
|
||||||
|
|
||||||
|
await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
|
||||||
|
|
||||||
|
await VideoSourceModel.create({
|
||||||
|
filename: originalFilename,
|
||||||
|
videoId: video.id,
|
||||||
|
createdAt: inputFileUpdatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
await regenerateMiniaturesIfNeeded(video)
|
||||||
|
await video.VideoChannel.setAsUpdated()
|
||||||
|
await addVideoJobsAfterUpload(video, video.getMaxQualityFile())
|
||||||
|
|
||||||
|
logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))
|
||||||
|
|
||||||
|
Hooks.runAction('action:api.video.file-updated', { video, req, res })
|
||||||
|
|
||||||
|
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
|
||||||
|
} finally {
|
||||||
|
videoFileMutexReleaser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
|
||||||
|
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
|
||||||
|
{
|
||||||
|
type: 'manage-video-torrent' as 'manage-video-torrent',
|
||||||
|
payload: {
|
||||||
|
videoId: video.id,
|
||||||
|
videoFileId: videoFile.id,
|
||||||
|
action: 'create'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
// No need to federate, we process these jobs sequentially
|
||||||
|
federate: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'federate-video' as 'federate-video',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
isNewVideo: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
|
||||||
|
jobs.push(await buildMoveToObjectStorageJob({ video, isNewVideo: false, previousVideoState: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.state === VideoState.TO_TRANSCODE) {
|
||||||
|
jobs.push({
|
||||||
|
type: 'transcoding-job-builder' as 'transcoding-job-builder',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
optimizeJob: {
|
||||||
|
isNewVideo: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return JobQueue.Instance.createSequentialJobFlow(...jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOldFiles (options: {
|
||||||
|
video: MVideo
|
||||||
|
files: MVideoFile[]
|
||||||
|
playlists: MStreamingPlaylistFiles[]
|
||||||
|
}) {
|
||||||
|
const { video, files, playlists } = options
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await video.removeWebVideoFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
await video.removeStreamingPlaylistFiles(playlist)
|
||||||
|
}
|
||||||
|
}
|
|
@ -130,6 +130,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
|
||||||
user: res.locals.oauth.token.User,
|
user: res.locals.oauth.token.User,
|
||||||
isRemote: false,
|
isRemote: false,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
isNewFile: false,
|
||||||
transaction: t
|
transaction: t
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,9 @@ import { buildNewFile } from '@server/lib/video-file'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
import { buildNextVideoState } from '@server/lib/video-state'
|
import { buildNextVideoState } from '@server/lib/video-state'
|
||||||
import { openapiOperationDoc } from '@server/middlewares/doc'
|
import { openapiOperationDoc } from '@server/middlewares/doc'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
import { VideoSourceModel } from '@server/models/video/video-source'
|
import { VideoSourceModel } from '@server/models/video/video-source'
|
||||||
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
|
import { MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||||
import { uuidToShort } from '@shared/extra-utils'
|
import { uuidToShort } from '@shared/extra-utils'
|
||||||
import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
|
import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
|
||||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||||
|
@ -33,7 +34,6 @@ import {
|
||||||
} from '../../../middlewares'
|
} from '../../../middlewares'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
import { VideoPasswordModel } from '@server/models/video/video-password'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('api', 'video')
|
const lTags = loggerTagsFactory('api', 'video')
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
|
@ -109,7 +109,7 @@ async function addVideoLegacy (req: express.Request, res: express.Response) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addVideoResumable (req: express.Request, res: express.Response) {
|
async function addVideoResumable (req: express.Request, res: express.Response) {
|
||||||
const videoPhysicalFile = res.locals.videoFileResumable
|
const videoPhysicalFile = res.locals.uploadVideoFileResumable
|
||||||
const videoInfo = videoPhysicalFile.metadata
|
const videoInfo = videoPhysicalFile.metadata
|
||||||
const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile }
|
const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile }
|
||||||
|
|
||||||
|
@ -193,6 +193,7 @@ async function addVideo (options: {
|
||||||
user,
|
user,
|
||||||
isRemote: false,
|
isRemote: false,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
|
isNewFile: true,
|
||||||
transaction: t
|
transaction: t
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -209,7 +210,7 @@ async function addVideo (options: {
|
||||||
// Channel has a new content, set as updated
|
// Channel has a new content, set as updated
|
||||||
await videoCreated.VideoChannel.setAsUpdated()
|
await videoCreated.VideoChannel.setAsUpdated()
|
||||||
|
|
||||||
addVideoJobsAfterUpload(videoCreated, videoFile, user)
|
addVideoJobsAfterUpload(videoCreated, videoFile)
|
||||||
.catch(err => logger.error('Cannot build new video jobs of %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
|
.catch(err => logger.error('Cannot build new video jobs of %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
|
||||||
|
|
||||||
Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res })
|
Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res })
|
||||||
|
@ -223,7 +224,7 @@ async function addVideo (options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile, user: MUserId) {
|
async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
|
||||||
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
|
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
|
||||||
{
|
{
|
||||||
type: 'manage-video-torrent' as 'manage-video-torrent',
|
type: 'manage-video-torrent' as 'manage-video-torrent',
|
||||||
|
|
|
@ -76,6 +76,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
|
||||||
isDateValid(video.published) &&
|
isDateValid(video.published) &&
|
||||||
isDateValid(video.updated) &&
|
isDateValid(video.updated) &&
|
||||||
(!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) &&
|
(!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) &&
|
||||||
|
(!video.uploadDate || isDateValid(video.uploadDate)) &&
|
||||||
(!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) &&
|
(!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) &&
|
||||||
video.attributedTo.length !== 0
|
video.attributedTo.length !== 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,6 +63,8 @@ async function generateImageFromVideoFile (options: {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
|
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ function checkMissedConfig () {
|
||||||
'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
|
'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
|
||||||
'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
|
'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
|
||||||
'video_studio.enabled', 'video_studio.remote_runners.enabled',
|
'video_studio.enabled', 'video_studio.remote_runners.enabled',
|
||||||
|
'video_file.update.enabled',
|
||||||
'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live',
|
'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live',
|
||||||
'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout',
|
'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout',
|
||||||
'import.video_channel_synchronization.enabled', 'import.video_channel_synchronization.max_per_user',
|
'import.video_channel_synchronization.enabled', 'import.video_channel_synchronization.max_per_user',
|
||||||
|
|
|
@ -435,6 +435,11 @@ const CONFIG = {
|
||||||
get ENABLED () { return config.get<boolean>('video_studio.remote_runners.enabled') }
|
get ENABLED () { return config.get<boolean>('video_studio.remote_runners.enabled') }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
VIDEO_FILE: {
|
||||||
|
UPDATE: {
|
||||||
|
get ENABLED () { return config.get<boolean>('video_file.update.enabled') }
|
||||||
|
}
|
||||||
|
},
|
||||||
IMPORT: {
|
IMPORT: {
|
||||||
VIDEOS: {
|
VIDEOS: {
|
||||||
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
|
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const LAST_MIGRATION_VERSION = 795
|
const LAST_MIGRATION_VERSION = 800
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
const { transaction } = utils
|
||||||
|
|
||||||
|
{
|
||||||
|
const query = 'DELETE FROM "videoSource" WHERE "videoId" IS NULL'
|
||||||
|
await utils.sequelize.query(query, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const query = 'ALTER TABLE "videoSource" ALTER COLUMN "videoId" SET NOT NULL'
|
||||||
|
await utils.sequelize.query(query, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const data = {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
|
}
|
||||||
|
|
||||||
|
await utils.queryInterface.addColumn('video', 'inputFileUpdatedAt', data, { transaction })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
up,
|
||||||
|
down
|
||||||
|
}
|
|
@ -60,6 +60,9 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
|
||||||
},
|
},
|
||||||
|
|
||||||
originallyPublishedAt: 'sc:datePublished',
|
originallyPublishedAt: 'sc:datePublished',
|
||||||
|
|
||||||
|
uploadDate: 'sc:uploadDate',
|
||||||
|
|
||||||
views: {
|
views: {
|
||||||
'@type': 'sc:Number',
|
'@type': 'sc:Number',
|
||||||
'@id': 'pt:views'
|
'@id': 'pt:views'
|
||||||
|
|
|
@ -49,6 +49,7 @@ export class APVideoCreator extends APVideoAbstractBuilder {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
|
isNewFile: true,
|
||||||
transaction: t
|
transaction: t
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -231,6 +231,10 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
|
||||||
? new Date(videoObject.originallyPublishedAt)
|
? new Date(videoObject.originallyPublishedAt)
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
|
inputFileUpdatedAt: videoObject.uploadDate
|
||||||
|
? new Date(videoObject.uploadDate)
|
||||||
|
: null,
|
||||||
|
|
||||||
updatedAt: new Date(videoObject.updated),
|
updatedAt: new Date(videoObject.updated),
|
||||||
views: videoObject.views,
|
views: videoObject.views,
|
||||||
remote: true,
|
remote: true,
|
||||||
|
|
|
@ -38,6 +38,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||||
{ videoObject: this.videoObject, ...this.lTags() }
|
{ videoObject: this.videoObject, ...this.lTags() }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
|
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
|
||||||
|
|
||||||
|
@ -74,6 +76,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt,
|
||||||
transaction: undefined
|
transaction: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -129,6 +132,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||||
this.video.createdAt = videoData.createdAt
|
this.video.createdAt = videoData.createdAt
|
||||||
this.video.publishedAt = videoData.publishedAt
|
this.video.publishedAt = videoData.publishedAt
|
||||||
this.video.originallyPublishedAt = videoData.originallyPublishedAt
|
this.video.originallyPublishedAt = videoData.originallyPublishedAt
|
||||||
|
this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt
|
||||||
this.video.privacy = videoData.privacy
|
this.video.privacy = videoData.privacy
|
||||||
this.video.channelId = videoData.channelId
|
this.video.channelId = videoData.channelId
|
||||||
this.video.views = videoData.views
|
this.video.views = videoData.views
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
|
||||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
||||||
import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
|
import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
|
||||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths'
|
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths'
|
||||||
import { generateLocalVideoMiniature } from '@server/lib/thumbnail'
|
import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail'
|
||||||
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding'
|
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
import { moveToNextState } from '@server/lib/video-state'
|
import { moveToNextState } from '@server/lib/video-state'
|
||||||
|
@ -197,23 +197,7 @@ async function replaceLiveByReplay (options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate the thumbnail & preview?
|
// Regenerate the thumbnail & preview?
|
||||||
if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
|
await regenerateMiniaturesIfNeeded(videoWithFiles)
|
||||||
const miniature = await generateLocalVideoMiniature({
|
|
||||||
video: videoWithFiles,
|
|
||||||
videoFile: videoWithFiles.getMaxQualityFile(),
|
|
||||||
type: ThumbnailType.MINIATURE
|
|
||||||
})
|
|
||||||
await videoWithFiles.addAndSaveThumbnail(miniature)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoWithFiles.getPreview().automaticallyGenerated === true) {
|
|
||||||
const preview = await generateLocalVideoMiniature({
|
|
||||||
video: videoWithFiles,
|
|
||||||
videoFile: videoWithFiles.getMaxQualityFile(),
|
|
||||||
type: ThumbnailType.PREVIEW
|
|
||||||
})
|
|
||||||
await videoWithFiles.addAndSaveThumbnail(preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We consider this is a new video
|
// We consider this is a new video
|
||||||
await moveToNextState({ video: videoWithFiles, isNewVideo: true })
|
await moveToNextState({ video: videoWithFiles, isNewVideo: true })
|
||||||
|
|
|
@ -36,7 +36,7 @@ export type AcceptResult = {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Stub function that can be filtered by plugins
|
// Stub function that can be filtered by plugins
|
||||||
function isLocalVideoAccepted (object: {
|
function isLocalVideoFileAccepted (object: {
|
||||||
videoBody: VideoCreate
|
videoBody: VideoCreate
|
||||||
videoFile: VideoUploadFile
|
videoFile: VideoUploadFile
|
||||||
user: UserModel
|
user: UserModel
|
||||||
|
@ -201,7 +201,7 @@ function createAccountAbuse (options: {
|
||||||
export {
|
export {
|
||||||
isLocalLiveVideoAccepted,
|
isLocalLiveVideoAccepted,
|
||||||
|
|
||||||
isLocalVideoAccepted,
|
isLocalVideoFileAccepted,
|
||||||
isLocalVideoThreadAccepted,
|
isLocalVideoThreadAccepted,
|
||||||
isRemoteVideoCommentAccepted,
|
isRemoteVideoCommentAccepted,
|
||||||
isLocalVideoCommentReplyAccepted,
|
isLocalVideoCommentReplyAccepted,
|
||||||
|
|
|
@ -171,6 +171,11 @@ class ServerConfigManager {
|
||||||
enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
|
enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
|
||||||
|
}
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
http: {
|
http: {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { generateImageFilename, generateImageFromVideoFile } from '../helpers/im
|
||||||
import { CONFIG } from '../initializers/config'
|
import { CONFIG } from '../initializers/config'
|
||||||
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
|
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
|
||||||
import { ThumbnailModel } from '../models/video/thumbnail'
|
import { ThumbnailModel } from '../models/video/thumbnail'
|
||||||
import { MVideoFile, MVideoThumbnail, MVideoUUID } from '../types/models'
|
import { MVideoFile, MVideoThumbnail, MVideoUUID, MVideoWithAllFiles } from '../types/models'
|
||||||
import { MThumbnail } from '../types/models/video/thumbnail'
|
import { MThumbnail } from '../types/models/video/thumbnail'
|
||||||
import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist'
|
import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist'
|
||||||
import { VideoPathManager } from './video-path-manager'
|
import { VideoPathManager } from './video-path-manager'
|
||||||
|
@ -187,8 +187,31 @@ function updateRemoteVideoThumbnail (options: {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) {
|
||||||
|
if (video.getMiniature().automaticallyGenerated === true) {
|
||||||
|
const miniature = await generateLocalVideoMiniature({
|
||||||
|
video,
|
||||||
|
videoFile: video.getMaxQualityFile(),
|
||||||
|
type: ThumbnailType.MINIATURE
|
||||||
|
})
|
||||||
|
await video.addAndSaveThumbnail(miniature)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.getPreview().automaticallyGenerated === true) {
|
||||||
|
const preview = await generateLocalVideoMiniature({
|
||||||
|
video,
|
||||||
|
videoFile: video.getMaxQualityFile(),
|
||||||
|
type: ThumbnailType.PREVIEW
|
||||||
|
})
|
||||||
|
await video.addAndSaveThumbnail(preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
generateLocalVideoMiniature,
|
generateLocalVideoMiniature,
|
||||||
|
regenerateMiniaturesIfNeeded,
|
||||||
updateLocalVideoMiniatureFromUrl,
|
updateLocalVideoMiniatureFromUrl,
|
||||||
updateLocalVideoMiniatureFromExisting,
|
updateLocalVideoMiniatureFromExisting,
|
||||||
updateRemoteVideoThumbnail,
|
updateRemoteVideoThumbnail,
|
||||||
|
|
|
@ -27,13 +27,14 @@ async function autoBlacklistVideoIfNeeded (parameters: {
|
||||||
user?: MUser
|
user?: MUser
|
||||||
isRemote: boolean
|
isRemote: boolean
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
|
isNewFile: boolean
|
||||||
notify?: boolean
|
notify?: boolean
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
}) {
|
}) {
|
||||||
const { video, user, isRemote, isNew, notify = true, transaction } = parameters
|
const { video, user, isRemote, isNew, isNewFile, notify = true, transaction } = parameters
|
||||||
const doAutoBlacklist = await Hooks.wrapFun(
|
const doAutoBlacklist = await Hooks.wrapFun(
|
||||||
autoBlacklistNeeded,
|
autoBlacklistNeeded,
|
||||||
{ video, user, isRemote, isNew },
|
{ video, user, isRemote, isNew, isNewFile },
|
||||||
'filter:video.auto-blacklist.result'
|
'filter:video.auto-blacklist.result'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -128,14 +129,15 @@ function autoBlacklistNeeded (parameters: {
|
||||||
video: MVideoWithBlacklistLight
|
video: MVideoWithBlacklistLight
|
||||||
isRemote: boolean
|
isRemote: boolean
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
|
isNewFile: boolean
|
||||||
user?: MUser
|
user?: MUser
|
||||||
}) {
|
}) {
|
||||||
const { user, video, isRemote, isNew } = parameters
|
const { user, video, isRemote, isNew, isNewFile } = parameters
|
||||||
|
|
||||||
// Already blacklisted
|
// Already blacklisted
|
||||||
if (video.VideoBlacklist) return false
|
if (video.VideoBlacklist) return false
|
||||||
if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false
|
if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false
|
||||||
if (isRemote || isNew === false) return false
|
if (isRemote || (isNew === false && isNewFile === false)) return false
|
||||||
|
|
||||||
if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)) return false
|
if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)) return false
|
||||||
|
|
||||||
|
|
|
@ -89,6 +89,7 @@ async function insertFromImportIntoDB (parameters: {
|
||||||
notify: false,
|
notify: false,
|
||||||
isRemote: false,
|
isRemote: false,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
|
isNewFile: true,
|
||||||
transaction: t
|
transaction: t
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,8 @@ const customConfigUpdateValidator = [
|
||||||
body('videoStudio.enabled').isBoolean(),
|
body('videoStudio.enabled').isBoolean(),
|
||||||
body('videoStudio.remoteRunners.enabled').isBoolean(),
|
body('videoStudio.remoteRunners.enabled').isBoolean(),
|
||||||
|
|
||||||
|
body('videoFile.update.enabled').isBoolean(),
|
||||||
|
|
||||||
body('import.videos.concurrency').isInt({ min: 0 }),
|
body('import.videos.concurrency').isInt({ min: 0 }),
|
||||||
body('import.videos.http.enabled').isBoolean(),
|
body('import.videos.http.enabled').isBoolean(),
|
||||||
body('import.videos.torrent.enabled').isBoolean(),
|
body('import.videos.torrent.enabled').isBoolean(),
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
export * from './video-blacklist'
|
export * from './video-blacklist'
|
||||||
export * from './video-captions'
|
export * from './video-captions'
|
||||||
|
export * from './video-channel-sync'
|
||||||
export * from './video-channels'
|
export * from './video-channels'
|
||||||
export * from './video-comments'
|
export * from './video-comments'
|
||||||
export * from './video-files'
|
export * from './video-files'
|
||||||
export * from './video-imports'
|
export * from './video-imports'
|
||||||
export * from './video-live'
|
export * from './video-live'
|
||||||
export * from './video-ownership-changes'
|
export * from './video-ownership-changes'
|
||||||
export * from './video-view'
|
export * from './video-passwords'
|
||||||
export * from './video-rates'
|
export * from './video-rates'
|
||||||
export * from './video-shares'
|
export * from './video-shares'
|
||||||
export * from './video-source'
|
export * from './video-source'
|
||||||
|
@ -14,6 +15,5 @@ export * from './video-stats'
|
||||||
export * from './video-studio'
|
export * from './video-studio'
|
||||||
export * from './video-token'
|
export * from './video-token'
|
||||||
export * from './video-transcoding'
|
export * from './video-transcoding'
|
||||||
|
export * from './video-view'
|
||||||
export * from './videos'
|
export * from './videos'
|
||||||
export * from './video-channel-sync'
|
|
||||||
export * from './video-passwords'
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './upload'
|
||||||
|
export * from './video-validators'
|
|
@ -0,0 +1,39 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { logger } from '@server/helpers/logger'
|
||||||
|
import { getVideoStreamDuration } from '@shared/ffmpeg'
|
||||||
|
import { HttpStatusCode } from '@shared/models'
|
||||||
|
|
||||||
|
export async function addDurationToVideoFileIfNeeded (options: {
|
||||||
|
res: express.Response
|
||||||
|
videoFile: { path: string, duration?: number }
|
||||||
|
middlewareName: string
|
||||||
|
}) {
|
||||||
|
const { res, middlewareName, videoFile } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!videoFile.duration) await addDurationToVideo(videoFile)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Invalid input file in ' + middlewareName, { err })
|
||||||
|
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
|
||||||
|
message: 'Video file unreadable.'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
|
||||||
|
const duration = await getVideoStreamDuration(videoFile.path)
|
||||||
|
|
||||||
|
// FFmpeg may not be able to guess video duration
|
||||||
|
// For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
|
||||||
|
if (isNaN(duration)) videoFile.duration = 0
|
||||||
|
else videoFile.duration = duration
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { isVideoFileMimeTypeValid, isVideoFileSizeValid } from '@server/helpers/custom-validators/videos'
|
||||||
|
import { logger } from '@server/helpers/logger'
|
||||||
|
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
|
||||||
|
import { isLocalVideoFileAccepted } from '@server/lib/moderation'
|
||||||
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
|
import { MUserAccountId, MVideo } from '@server/types/models'
|
||||||
|
import { HttpStatusCode, ServerErrorCode, ServerFilterHookName, VideoState } from '@shared/models'
|
||||||
|
import { checkUserQuota } from '../../shared'
|
||||||
|
|
||||||
|
export async function commonVideoFileChecks (options: {
|
||||||
|
res: express.Response
|
||||||
|
user: MUserAccountId
|
||||||
|
videoFileSize: number
|
||||||
|
files: express.UploadFilesForCheck
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { res, user, videoFileSize, files } = options
|
||||||
|
|
||||||
|
if (!isVideoFileMimeTypeValid(files)) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
|
||||||
|
message: 'This file is not supported. Please, make sure it is of the following type: ' +
|
||||||
|
CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVideoFileSizeValid(videoFileSize.toString())) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
||||||
|
message: 'This file is too large. It exceeds the maximum file size authorized.',
|
||||||
|
type: ServerErrorCode.MAX_FILE_SIZE_REACHED
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await checkUserQuota(user, videoFileSize, res) === false) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isVideoFileAccepted (options: {
|
||||||
|
req: express.Request
|
||||||
|
res: express.Response
|
||||||
|
videoFile: express.VideoUploadFile
|
||||||
|
hook: Extract<ServerFilterHookName, 'filter:api.video.upload.accept.result' | 'filter:api.video.update-file.accept.result'>
|
||||||
|
}) {
|
||||||
|
const { req, res, videoFile } = options
|
||||||
|
|
||||||
|
// Check we accept this video
|
||||||
|
const acceptParameters = {
|
||||||
|
videoBody: req.body,
|
||||||
|
videoFile,
|
||||||
|
user: res.locals.oauth.token.User
|
||||||
|
}
|
||||||
|
const acceptedResult = await Hooks.wrapFun(
|
||||||
|
isLocalVideoFileAccepted,
|
||||||
|
acceptParameters,
|
||||||
|
'filter:api.video.upload.accept.result'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!acceptedResult || acceptedResult.accepted !== true) {
|
||||||
|
logger.info('Refused local video file.', { acceptedResult, acceptParameters })
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
message: acceptedResult.errorMessage || 'Refused local video file'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkVideoFileCanBeEdited (video: MVideo, res: express.Response) {
|
||||||
|
if (video.isLive) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: 'Cannot edit a live video'
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.CONFLICT_409,
|
||||||
|
message: 'Cannot edit video that is already waiting for transcoding/edition'
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStates = new Set([ VideoState.PUBLISHED, VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED, VideoState.TRANSCODING_FAILED ])
|
||||||
|
if (!validStates.has(video.state)) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: 'Video state is not compatible with edition'
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
|
@ -1,20 +1,31 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { body, header } from 'express-validator'
|
||||||
|
import { getResumableUploadPath } from '@server/helpers/upload'
|
||||||
import { getVideoWithAttributes } from '@server/helpers/video'
|
import { getVideoWithAttributes } from '@server/helpers/video'
|
||||||
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { uploadx } from '@server/lib/uploadx'
|
||||||
import { VideoSourceModel } from '@server/models/video/video-source'
|
import { VideoSourceModel } from '@server/models/video/video-source'
|
||||||
import { MVideoFullLight } from '@server/types/models'
|
import { MVideoFullLight } from '@server/types/models'
|
||||||
import { HttpStatusCode, UserRight } from '@shared/models'
|
import { HttpStatusCode, UserRight } from '@shared/models'
|
||||||
|
import { Metadata as UploadXMetadata } from '@uploadx/core'
|
||||||
|
import { logger } from '../../../helpers/logger'
|
||||||
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
|
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
|
||||||
|
import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared'
|
||||||
|
|
||||||
const videoSourceGetValidator = [
|
export const videoSourceGetLatestValidator = [
|
||||||
isValidVideoIdParam('id'),
|
isValidVideoIdParam('id'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
if (!await doesVideoExist(req.params.id, res, 'for-api')) return
|
if (!await doesVideoExist(req.params.id, res, 'all')) return
|
||||||
|
|
||||||
const video = getVideoWithAttributes(res) as MVideoFullLight
|
const video = getVideoWithAttributes(res) as MVideoFullLight
|
||||||
|
|
||||||
res.locals.videoSource = await VideoSourceModel.loadByVideoId(video.id)
|
const user = res.locals.oauth.token.User
|
||||||
|
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return
|
||||||
|
|
||||||
|
res.locals.videoSource = await VideoSourceModel.loadLatest(video.id)
|
||||||
|
|
||||||
if (!res.locals.videoSource) {
|
if (!res.locals.videoSource) {
|
||||||
return res.fail({
|
return res.fail({
|
||||||
status: HttpStatusCode.NOT_FOUND_404,
|
status: HttpStatusCode.NOT_FOUND_404,
|
||||||
|
@ -22,13 +33,98 @@ const videoSourceGetValidator = [
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = res.locals.oauth.token.User
|
return next()
|
||||||
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const replaceVideoSourceResumableValidator = [
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
const body: express.CustomUploadXFile<UploadXMetadata> = req.body
|
||||||
|
const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
|
||||||
|
const cleanup = () => uploadx.storage.delete(file).catch(err => logger.error('Cannot delete the file %s', file.name, { err }))
|
||||||
|
|
||||||
|
if (!await checkCanUpdateVideoFile({ req, res })) {
|
||||||
|
return cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'updateVideoFileResumableValidator' })) {
|
||||||
|
return cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.update-file.accept.result' })) {
|
||||||
|
return cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.updateVideoFileResumable = { ...file, originalname: file.filename }
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export {
|
export const replaceVideoSourceResumableInitValidator = [
|
||||||
videoSourceGetValidator
|
body('filename')
|
||||||
|
.exists(),
|
||||||
|
|
||||||
|
header('x-upload-content-length')
|
||||||
|
.isNumeric()
|
||||||
|
.exists()
|
||||||
|
.withMessage('Should specify the file length'),
|
||||||
|
header('x-upload-content-type')
|
||||||
|
.isString()
|
||||||
|
.exists()
|
||||||
|
.withMessage('Should specify the file mimetype'),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
|
||||||
|
logger.debug('Checking updateVideoFileResumableInitValidator parameters and headers', {
|
||||||
|
parameters: req.body,
|
||||||
|
headers: req.headers
|
||||||
|
})
|
||||||
|
|
||||||
|
if (areValidationErrors(req, res, { omitLog: true })) return
|
||||||
|
|
||||||
|
if (!await checkCanUpdateVideoFile({ req, res })) return
|
||||||
|
|
||||||
|
const videoFileMetadata = {
|
||||||
|
mimetype: req.headers['x-upload-content-type'] as string,
|
||||||
|
size: +req.headers['x-upload-content-length'],
|
||||||
|
originalname: req.body.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = { videofile: [ videoFileMetadata ] }
|
||||||
|
if (await commonVideoFileChecks({ res, user, videoFileSize: videoFileMetadata.size, files }) === false) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function checkCanUpdateVideoFile (options: {
|
||||||
|
req: express.Request
|
||||||
|
res: express.Response
|
||||||
|
}) {
|
||||||
|
const { req, res } = options
|
||||||
|
|
||||||
|
if (!CONFIG.VIDEO_FILE.UPDATE.ENABLED) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
message: 'Updating the file of an existing video is not allowed on this instance'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await doesVideoExist(req.params.id, res)) return false
|
||||||
|
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
const video = res.locals.videoAll
|
||||||
|
|
||||||
|
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return false
|
||||||
|
|
||||||
|
if (!checkVideoFileCanBeEdited(video, res)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,9 @@ import { cleanUpReqFiles } from '@server/helpers/express-utils'
|
||||||
import { CONFIG } from '@server/initializers/config'
|
import { CONFIG } from '@server/initializers/config'
|
||||||
import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio'
|
import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio'
|
||||||
import { isAudioFile } from '@shared/ffmpeg'
|
import { isAudioFile } from '@shared/ffmpeg'
|
||||||
import { HttpStatusCode, UserRight, VideoState, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models'
|
import { HttpStatusCode, UserRight, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models'
|
||||||
import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
|
import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
|
||||||
|
import { checkVideoFileCanBeEdited } from './shared'
|
||||||
|
|
||||||
const videoStudioAddEditionValidator = [
|
const videoStudioAddEditionValidator = [
|
||||||
param('videoId')
|
param('videoId')
|
||||||
|
@ -66,14 +67,7 @@ const videoStudioAddEditionValidator = [
|
||||||
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
|
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
|
if (!checkVideoFileCanBeEdited(video, res)) return cleanUpReqFiles(req)
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.CONFLICT_409,
|
|
||||||
message: 'Cannot edit video that is already waiting for transcoding/edition'
|
|
||||||
})
|
|
||||||
|
|
||||||
return cleanUpReqFiles(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = res.locals.oauth.token.User
|
const user = res.locals.oauth.token.User
|
||||||
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
|
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
|
||||||
|
|
|
@ -2,13 +2,12 @@ import express from 'express'
|
||||||
import { body, header, param, query, ValidationChain } from 'express-validator'
|
import { body, header, param, query, ValidationChain } from 'express-validator'
|
||||||
import { isTestInstance } from '@server/helpers/core-utils'
|
import { isTestInstance } from '@server/helpers/core-utils'
|
||||||
import { getResumableUploadPath } from '@server/helpers/upload'
|
import { getResumableUploadPath } from '@server/helpers/upload'
|
||||||
import { uploadx } from '@server/lib/uploadx'
|
|
||||||
import { Redis } from '@server/lib/redis'
|
import { Redis } from '@server/lib/redis'
|
||||||
|
import { uploadx } from '@server/lib/uploadx'
|
||||||
import { getServerActor } from '@server/models/application/application'
|
import { getServerActor } from '@server/models/application/application'
|
||||||
import { ExpressPromiseHandler } from '@server/types/express-handler'
|
import { ExpressPromiseHandler } from '@server/types/express-handler'
|
||||||
import { MUserAccountId, MVideoFullLight } from '@server/types/models'
|
import { MUserAccountId, MVideoFullLight } from '@server/types/models'
|
||||||
import { arrayify, getAllPrivacies } from '@shared/core-utils'
|
import { arrayify, getAllPrivacies } from '@shared/core-utils'
|
||||||
import { getVideoStreamDuration } from '@shared/ffmpeg'
|
|
||||||
import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models'
|
import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models'
|
||||||
import {
|
import {
|
||||||
exists,
|
exists,
|
||||||
|
@ -27,8 +26,6 @@ import {
|
||||||
isValidPasswordProtectedPrivacy,
|
isValidPasswordProtectedPrivacy,
|
||||||
isVideoCategoryValid,
|
isVideoCategoryValid,
|
||||||
isVideoDescriptionValid,
|
isVideoDescriptionValid,
|
||||||
isVideoFileMimeTypeValid,
|
|
||||||
isVideoFileSizeValid,
|
|
||||||
isVideoFilterValid,
|
isVideoFilterValid,
|
||||||
isVideoImageValid,
|
isVideoImageValid,
|
||||||
isVideoIncludeValid,
|
isVideoIncludeValid,
|
||||||
|
@ -44,21 +41,19 @@ import { logger } from '../../../helpers/logger'
|
||||||
import { getVideoWithAttributes } from '../../../helpers/video'
|
import { getVideoWithAttributes } from '../../../helpers/video'
|
||||||
import { CONFIG } from '../../../initializers/config'
|
import { CONFIG } from '../../../initializers/config'
|
||||||
import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
|
import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
|
||||||
import { isLocalVideoAccepted } from '../../../lib/moderation'
|
|
||||||
import { Hooks } from '../../../lib/plugins/hooks'
|
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
import {
|
import {
|
||||||
areValidationErrors,
|
areValidationErrors,
|
||||||
checkCanAccessVideoStaticFiles,
|
checkCanAccessVideoStaticFiles,
|
||||||
checkCanSeeVideo,
|
checkCanSeeVideo,
|
||||||
checkUserCanManageVideo,
|
checkUserCanManageVideo,
|
||||||
checkUserQuota,
|
|
||||||
doesVideoChannelOfAccountExist,
|
doesVideoChannelOfAccountExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
doesVideoFileOfVideoExist,
|
doesVideoFileOfVideoExist,
|
||||||
isValidVideoIdParam,
|
isValidVideoIdParam,
|
||||||
isValidVideoPasswordHeader
|
isValidVideoPasswordHeader
|
||||||
} from '../shared'
|
} from '../shared'
|
||||||
|
import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared'
|
||||||
|
|
||||||
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||||
body('videofile')
|
body('videofile')
|
||||||
|
@ -83,26 +78,15 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||||
const videoFile: express.VideoUploadFile = req.files['videofile'][0]
|
const videoFile: express.VideoUploadFile = req.files['videofile'][0]
|
||||||
const user = res.locals.oauth.token.User
|
const user = res.locals.oauth.token.User
|
||||||
|
|
||||||
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
|
if (
|
||||||
|
!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files }) ||
|
||||||
|
!isValidPasswordProtectedPrivacy(req, res) ||
|
||||||
|
!await addDurationToVideoFileIfNeeded({ videoFile, res, middlewareName: 'videosAddvideosAddLegacyValidatorResumableValidator' }) ||
|
||||||
|
!await isVideoFileAccepted({ req, res, videoFile, hook: 'filter:api.video.upload.accept.result' })
|
||||||
|
) {
|
||||||
return cleanUpReqFiles(req)
|
return cleanUpReqFiles(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!videoFile.duration) await addDurationToVideo(videoFile)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Invalid input file in videosAddLegacyValidator.', { err })
|
|
||||||
|
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
|
|
||||||
message: 'Video file unreadable.'
|
|
||||||
})
|
|
||||||
return cleanUpReqFiles(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
@ -146,22 +130,10 @@ const videosAddResumableValidator = [
|
||||||
await Redis.Instance.setUploadSession(uploadId)
|
await Redis.Instance.setUploadSession(uploadId)
|
||||||
|
|
||||||
if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
|
if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
|
||||||
|
if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'videosAddResumableValidator' })) return cleanup()
|
||||||
|
if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.upload.accept.result' })) return cleanup()
|
||||||
|
|
||||||
try {
|
res.locals.uploadVideoFileResumable = { ...file, originalname: file.filename }
|
||||||
if (!file.duration) await addDurationToVideo(file)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Invalid input file in videosAddResumableValidator.', { err })
|
|
||||||
|
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
|
|
||||||
message: 'Video file unreadable.'
|
|
||||||
})
|
|
||||||
return cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await isVideoAccepted(req, res, file)) return cleanup()
|
|
||||||
|
|
||||||
res.locals.videoFileResumable = { ...file, originalname: file.filename }
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
@ -604,76 +576,20 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commonVideoChecksPass (parameters: {
|
async function commonVideoChecksPass (options: {
|
||||||
req: express.Request
|
req: express.Request
|
||||||
res: express.Response
|
res: express.Response
|
||||||
user: MUserAccountId
|
user: MUserAccountId
|
||||||
videoFileSize: number
|
videoFileSize: number
|
||||||
files: express.UploadFilesForCheck
|
files: express.UploadFilesForCheck
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const { req, res, user, videoFileSize, files } = parameters
|
const { req, res, user } = options
|
||||||
|
|
||||||
if (areErrorsInScheduleUpdate(req, res)) return false
|
if (areErrorsInScheduleUpdate(req, res)) return false
|
||||||
|
|
||||||
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
|
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
|
||||||
|
|
||||||
if (!isVideoFileMimeTypeValid(files)) {
|
if (!await commonVideoFileChecks(options)) return false
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
|
|
||||||
message: 'This file is not supported. Please, make sure it is of the following type: ' +
|
|
||||||
CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isVideoFileSizeValid(videoFileSize.toString())) {
|
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
|
||||||
message: 'This file is too large. It exceeds the maximum file size authorized.',
|
|
||||||
type: ServerErrorCode.MAX_FILE_SIZE_REACHED
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await checkUserQuota(user, videoFileSize, res) === false) return false
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isVideoAccepted (
|
|
||||||
req: express.Request,
|
|
||||||
res: express.Response,
|
|
||||||
videoFile: express.VideoUploadFile
|
|
||||||
) {
|
|
||||||
// Check we accept this video
|
|
||||||
const acceptParameters = {
|
|
||||||
videoBody: req.body,
|
|
||||||
videoFile,
|
|
||||||
user: res.locals.oauth.token.User
|
|
||||||
}
|
|
||||||
const acceptedResult = await Hooks.wrapFun(
|
|
||||||
isLocalVideoAccepted,
|
|
||||||
acceptParameters,
|
|
||||||
'filter:api.video.upload.accept.result'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!acceptedResult || acceptedResult.accepted !== true) {
|
|
||||||
logger.info('Refused local video.', { acceptedResult, acceptParameters })
|
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.FORBIDDEN_403,
|
|
||||||
message: acceptedResult.errorMessage || 'Refused local video'
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
|
|
||||||
const duration = await getVideoStreamDuration(videoFile.path)
|
|
||||||
|
|
||||||
// FFmpeg may not be able to guess video duration
|
|
||||||
// For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
|
|
||||||
if (isNaN(duration)) videoFile.duration = 0
|
|
||||||
else videoFile.duration = duration
|
|
||||||
}
|
|
||||||
|
|
|
@ -76,6 +76,8 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||||
|
|
||||||
updated: video.updatedAt.toISOString(),
|
updated: video.updatedAt.toISOString(),
|
||||||
|
|
||||||
|
uploadDate: video.inputFileUpdatedAt?.toISOString(),
|
||||||
|
|
||||||
tag: buildTags(video),
|
tag: buildTags(video),
|
||||||
|
|
||||||
mediaType: 'text/markdown',
|
mediaType: 'text/markdown',
|
||||||
|
|
|
@ -149,6 +149,7 @@ export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetail
|
||||||
commentsEnabled: video.commentsEnabled,
|
commentsEnabled: video.commentsEnabled,
|
||||||
downloadEnabled: video.downloadEnabled,
|
downloadEnabled: video.downloadEnabled,
|
||||||
waitTranscoding: video.waitTranscoding,
|
waitTranscoding: video.waitTranscoding,
|
||||||
|
inputFileUpdatedAt: video.inputFileUpdatedAt,
|
||||||
state: {
|
state: {
|
||||||
id: video.state,
|
id: video.state,
|
||||||
label: getStateLabel(video.state)
|
label: getStateLabel(video.state)
|
||||||
|
|
|
@ -263,6 +263,7 @@ export class VideoTableAttributes {
|
||||||
'state',
|
'state',
|
||||||
'publishedAt',
|
'publishedAt',
|
||||||
'originallyPublishedAt',
|
'originallyPublishedAt',
|
||||||
|
'inputFileUpdatedAt',
|
||||||
'channelId',
|
'channelId',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
|
|
|
@ -1,27 +1,18 @@
|
||||||
import { Op } from 'sequelize'
|
import { Transaction } from 'sequelize'
|
||||||
import {
|
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
AllowNull,
|
import { VideoSource } from '@shared/models/videos/video-source'
|
||||||
BelongsTo,
|
|
||||||
Column,
|
|
||||||
CreatedAt,
|
|
||||||
ForeignKey,
|
|
||||||
Model,
|
|
||||||
Table,
|
|
||||||
UpdatedAt
|
|
||||||
} from 'sequelize-typescript'
|
|
||||||
import { AttributesOnly } from '@shared/typescript-utils'
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
|
import { getSort } from '../shared'
|
||||||
import { VideoModel } from './video'
|
import { VideoModel } from './video'
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoSource',
|
tableName: 'videoSource',
|
||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
fields: [ 'videoId' ],
|
fields: [ 'videoId' ]
|
||||||
where: {
|
},
|
||||||
videoId: {
|
{
|
||||||
[Op.ne]: null
|
fields: [ { name: 'createdAt', order: 'DESC' } ]
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -40,16 +31,26 @@ export class VideoSourceModel extends Model<Partial<AttributesOnly<VideoSourceMo
|
||||||
@Column
|
@Column
|
||||||
videoId: number
|
videoId: number
|
||||||
|
|
||||||
@BelongsTo(() => VideoModel)
|
@BelongsTo(() => VideoModel, {
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
onDelete: 'cascade'
|
||||||
|
})
|
||||||
Video: VideoModel
|
Video: VideoModel
|
||||||
|
|
||||||
static loadByVideoId (videoId) {
|
static loadLatest (videoId: number, transaction?: Transaction) {
|
||||||
return VideoSourceModel.findOne({ where: { videoId } })
|
return VideoSourceModel.findOne({
|
||||||
|
where: { videoId },
|
||||||
|
order: getSort('-createdAt'),
|
||||||
|
transaction
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
toFormattedJSON () {
|
toFormattedJSON (): VideoSource {
|
||||||
return {
|
return {
|
||||||
filename: this.filename
|
filename: this.filename,
|
||||||
|
createdAt: this.createdAt.toISOString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -546,6 +546,12 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
@Column
|
@Column
|
||||||
state: VideoState
|
state: VideoState
|
||||||
|
|
||||||
|
// We already have the information in videoSource table for local videos, but we prefer to normalize it for performance
|
||||||
|
// And also to store the info from remote instances
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
inputFileUpdatedAt: Date
|
||||||
|
|
||||||
@CreatedAt
|
@CreatedAt
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
|
|
||||||
|
@ -610,7 +616,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
@HasOne(() => VideoSourceModel, {
|
@HasOne(() => VideoSourceModel, {
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'videoId',
|
name: 'videoId',
|
||||||
allowNull: true
|
allowNull: false
|
||||||
},
|
},
|
||||||
onDelete: 'CASCADE'
|
onDelete: 'CASCADE'
|
||||||
})
|
})
|
||||||
|
|
|
@ -170,6 +170,11 @@ describe('Test config API validators', function () {
|
||||||
enabled: true
|
enabled: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import { HttpStatusCode } from '@shared/models'
|
import { HttpStatusCode } from '@shared/models'
|
||||||
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createSingleServer,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
setDefaultVideoChannel,
|
||||||
|
waitJobs
|
||||||
|
} from '@shared/server-commands'
|
||||||
|
|
||||||
describe('Test video sources API validator', function () {
|
describe('Test video sources API validator', function () {
|
||||||
let server: PeerTubeServer = null
|
let server: PeerTubeServer = null
|
||||||
|
@ -7,15 +14,20 @@ describe('Test video sources API validator', function () {
|
||||||
let userToken: string
|
let userToken: string
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(30000)
|
this.timeout(120000)
|
||||||
|
|
||||||
server = await createSingleServer(1)
|
server = await createSingleServer(1)
|
||||||
await setAccessTokensToServers([ server ])
|
await setAccessTokensToServers([ server ])
|
||||||
|
await setDefaultVideoChannel([ server ])
|
||||||
|
|
||||||
|
userToken = await server.users.generateUserAndToken('user1')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When getting latest source', function () {
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
const created = await server.videos.quickUpload({ name: 'video' })
|
const created = await server.videos.quickUpload({ name: 'video' })
|
||||||
uuid = created.uuid
|
uuid = created.uuid
|
||||||
|
|
||||||
userToken = await server.users.generateUserAndToken('user')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail without a valid uuid', async function () {
|
it('Should fail without a valid uuid', async function () {
|
||||||
|
@ -37,6 +49,104 @@ describe('Test video sources API validator', function () {
|
||||||
it('Should succeed with the correct parameters get the source as another user', async function () {
|
it('Should succeed with the correct parameters get the source as another user', async function () {
|
||||||
await server.videos.getSource({ id: uuid })
|
await server.videos.getSource({ id: uuid })
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When updating source video file', function () {
|
||||||
|
let userAccessToken: string
|
||||||
|
let userId: number
|
||||||
|
|
||||||
|
let videoId: string
|
||||||
|
let userVideoId: string
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
const res = await server.users.generate('user2')
|
||||||
|
userAccessToken = res.token
|
||||||
|
userId = res.userId
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({ name: 'video' })
|
||||||
|
videoId = uuid
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail if not enabled on the instance', async function () {
|
||||||
|
await server.config.disableFileUpdate()
|
||||||
|
|
||||||
|
await server.videos.replaceSourceFile({ videoId, fixture: 'video_short.mp4', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail on an unknown video', async function () {
|
||||||
|
await server.config.enableFileUpdate()
|
||||||
|
|
||||||
|
await server.videos.replaceSourceFile({ videoId: 404, fixture: 'video_short.mp4', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid video', async function () {
|
||||||
|
await server.config.enableLive({ allowReplay: false })
|
||||||
|
|
||||||
|
const { video } = await server.live.quickCreate({ saveReplay: false, permanentLive: true })
|
||||||
|
await server.videos.replaceSourceFile({
|
||||||
|
videoId: video.uuid,
|
||||||
|
fixture: 'video_short.mp4',
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without token', async function () {
|
||||||
|
await server.videos.replaceSourceFile({
|
||||||
|
token: null,
|
||||||
|
videoId,
|
||||||
|
fixture: 'video_short.mp4',
|
||||||
|
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with another user', async function () {
|
||||||
|
await server.videos.replaceSourceFile({
|
||||||
|
token: userAccessToken,
|
||||||
|
videoId,
|
||||||
|
fixture: 'video_short.mp4',
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an incorrect input file', async function () {
|
||||||
|
await server.videos.replaceSourceFile({
|
||||||
|
fixture: 'video_short_fake.webm',
|
||||||
|
videoId,
|
||||||
|
expectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.videos.replaceSourceFile({
|
||||||
|
fixture: 'video_short.mkv',
|
||||||
|
videoId,
|
||||||
|
expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail if quota is exceeded', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({ name: 'user video' })
|
||||||
|
userVideoId = uuid
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await server.users.update({ userId, videoQuota: 1 })
|
||||||
|
await server.videos.replaceSourceFile({
|
||||||
|
token: userAccessToken,
|
||||||
|
videoId: uuid,
|
||||||
|
fixture: 'video_short.mp4',
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct params', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
await server.users.update({ userId, videoQuota: 1000 * 1000 * 1000 })
|
||||||
|
await server.videos.replaceSourceFile({ videoId: userVideoId, fixture: 'video_short.mp4' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests([ server ])
|
await cleanupTests([ server ])
|
||||||
|
|
|
@ -105,6 +105,8 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
|
||||||
expect(data.videoStudio.enabled).to.be.false
|
expect(data.videoStudio.enabled).to.be.false
|
||||||
expect(data.videoStudio.remoteRunners.enabled).to.be.false
|
expect(data.videoStudio.remoteRunners.enabled).to.be.false
|
||||||
|
|
||||||
|
expect(data.videoFile.update.enabled).to.be.false
|
||||||
|
|
||||||
expect(data.import.videos.concurrency).to.equal(2)
|
expect(data.import.videos.concurrency).to.equal(2)
|
||||||
expect(data.import.videos.http.enabled).to.be.true
|
expect(data.import.videos.http.enabled).to.be.true
|
||||||
expect(data.import.videos.torrent.enabled).to.be.true
|
expect(data.import.videos.torrent.enabled).to.be.true
|
||||||
|
@ -216,6 +218,8 @@ function checkUpdatedConfig (data: CustomConfig) {
|
||||||
expect(data.videoStudio.enabled).to.be.true
|
expect(data.videoStudio.enabled).to.be.true
|
||||||
expect(data.videoStudio.remoteRunners.enabled).to.be.true
|
expect(data.videoStudio.remoteRunners.enabled).to.be.true
|
||||||
|
|
||||||
|
expect(data.videoFile.update.enabled).to.be.true
|
||||||
|
|
||||||
expect(data.import.videos.concurrency).to.equal(4)
|
expect(data.import.videos.concurrency).to.equal(4)
|
||||||
expect(data.import.videos.http.enabled).to.be.false
|
expect(data.import.videos.http.enabled).to.be.false
|
||||||
expect(data.import.videos.torrent.enabled).to.be.false
|
expect(data.import.videos.torrent.enabled).to.be.false
|
||||||
|
@ -386,6 +390,11 @@ const newCustomConfig: CustomConfig = {
|
||||||
enabled: true
|
enabled: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: 4,
|
concurrency: 4,
|
||||||
|
|
|
@ -13,11 +13,11 @@ import './video-imports'
|
||||||
import './video-nsfw'
|
import './video-nsfw'
|
||||||
import './video-playlists'
|
import './video-playlists'
|
||||||
import './video-playlist-thumbnails'
|
import './video-playlist-thumbnails'
|
||||||
|
import './video-source'
|
||||||
import './video-privacy'
|
import './video-privacy'
|
||||||
import './video-schedule-update'
|
import './video-schedule-update'
|
||||||
import './videos-common-filters'
|
import './videos-common-filters'
|
||||||
import './videos-history'
|
import './videos-history'
|
||||||
import './videos-overview'
|
import './videos-overview'
|
||||||
import './video-source'
|
|
||||||
import './video-static-file-privacy'
|
import './video-static-file-privacy'
|
||||||
import './video-storyboard'
|
import './video-storyboard'
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
|
||||||
// Most classic resumable upload tests are done in other test suites
|
// Most classic resumable upload tests are done in other test suites
|
||||||
|
|
||||||
describe('Test resumable upload', function () {
|
describe('Test resumable upload', function () {
|
||||||
|
const path = '/api/v1/videos/upload-resumable'
|
||||||
const defaultFixture = 'video_short.mp4'
|
const defaultFixture = 'video_short.mp4'
|
||||||
let server: PeerTubeServer
|
let server: PeerTubeServer
|
||||||
let rootId: number
|
let rootId: number
|
||||||
|
@ -44,7 +45,7 @@ describe('Test resumable upload', function () {
|
||||||
|
|
||||||
const mimetype = 'video/mp4'
|
const mimetype = 'video/mp4'
|
||||||
|
|
||||||
const res = await server.videos.prepareResumableUpload({ token, attributes, size, mimetype, originalName, lastModified })
|
const res = await server.videos.prepareResumableUpload({ path, token, attributes, size, mimetype, originalName, lastModified })
|
||||||
|
|
||||||
return res.header['location'].split('?')[1]
|
return res.header['location'].split('?')[1]
|
||||||
}
|
}
|
||||||
|
@ -66,6 +67,7 @@ describe('Test resumable upload', function () {
|
||||||
|
|
||||||
return server.videos.sendResumableChunks({
|
return server.videos.sendResumableChunks({
|
||||||
token,
|
token,
|
||||||
|
path,
|
||||||
pathUploadId,
|
pathUploadId,
|
||||||
videoFilePath: absoluteFilePath,
|
videoFilePath: absoluteFilePath,
|
||||||
size,
|
size,
|
||||||
|
@ -125,7 +127,7 @@ describe('Test resumable upload', function () {
|
||||||
it('Should correctly delete files after an upload', async function () {
|
it('Should correctly delete files after an upload', async function () {
|
||||||
const uploadId = await prepareUpload()
|
const uploadId = await prepareUpload()
|
||||||
await sendChunks({ pathUploadId: uploadId })
|
await sendChunks({ pathUploadId: uploadId })
|
||||||
await server.videos.endResumableUpload({ pathUploadId: uploadId })
|
await server.videos.endResumableUpload({ path, pathUploadId: uploadId })
|
||||||
|
|
||||||
expect(await countResumableUploads()).to.equal(0)
|
expect(await countResumableUploads()).to.equal(0)
|
||||||
})
|
})
|
||||||
|
@ -251,7 +253,7 @@ describe('Test resumable upload', function () {
|
||||||
const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
|
const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
|
||||||
|
|
||||||
await sendChunks({ pathUploadId: uploadId1 })
|
await sendChunks({ pathUploadId: uploadId1 })
|
||||||
await server.videos.endResumableUpload({ pathUploadId: uploadId1 })
|
await server.videos.endResumableUpload({ path, pathUploadId: uploadId1 })
|
||||||
|
|
||||||
const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
|
const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
|
||||||
expect(uploadId1).to.equal(uploadId2)
|
expect(uploadId1).to.equal(uploadId2)
|
||||||
|
|
|
@ -1,36 +1,447 @@
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
|
import { expectStartWith } from '@server/tests/shared'
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
import { areMockObjectStorageTestsDisabled, getAllFiles } from '@shared/core-utils'
|
||||||
|
import { HttpStatusCode } from '@shared/models'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createMultipleServers,
|
||||||
|
doubleFollow,
|
||||||
|
makeGetRequest,
|
||||||
|
makeRawRequest,
|
||||||
|
ObjectStorageCommand,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
setDefaultAccountAvatar,
|
||||||
|
setDefaultVideoChannel,
|
||||||
|
waitJobs
|
||||||
|
} from '@shared/server-commands'
|
||||||
|
|
||||||
describe('Test video source', () => {
|
describe('Test a video file replacement', function () {
|
||||||
let server: PeerTubeServer = null
|
let servers: PeerTubeServer[] = []
|
||||||
const fixture = 'video_short.webm'
|
|
||||||
|
let replaceDate: Date
|
||||||
|
let userToken: string
|
||||||
|
let uuid: string
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(30000)
|
this.timeout(50000)
|
||||||
|
|
||||||
server = await createSingleServer(1)
|
servers = await createMultipleServers(2)
|
||||||
await setAccessTokensToServers([ server ])
|
|
||||||
|
// Get the access tokens
|
||||||
|
await setAccessTokensToServers(servers)
|
||||||
|
await setDefaultVideoChannel(servers)
|
||||||
|
await setDefaultAccountAvatar(servers)
|
||||||
|
|
||||||
|
await servers[0].config.enableFileUpdate()
|
||||||
|
|
||||||
|
userToken = await servers[0].users.generateUserAndToken('user1')
|
||||||
|
|
||||||
|
// Server 1 and server 2 follow each other
|
||||||
|
await doubleFollow(servers[0], servers[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Getting latest video source', () => {
|
||||||
|
const fixture = 'video_short.webm'
|
||||||
|
const uuids: string[] = []
|
||||||
|
|
||||||
it('Should get the source filename with legacy upload', async function () {
|
it('Should get the source filename with legacy upload', async function () {
|
||||||
this.timeout(30000)
|
this.timeout(30000)
|
||||||
|
|
||||||
const { uuid } = await server.videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' })
|
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' })
|
||||||
|
uuids.push(uuid)
|
||||||
|
|
||||||
const source = await server.videos.getSource({ id: uuid })
|
const source = await servers[0].videos.getSource({ id: uuid })
|
||||||
expect(source.filename).to.equal(fixture)
|
expect(source.filename).to.equal(fixture)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should get the source filename with resumable upload', async function () {
|
it('Should get the source filename with resumable upload', async function () {
|
||||||
this.timeout(30000)
|
this.timeout(30000)
|
||||||
|
|
||||||
const { uuid } = await server.videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' })
|
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' })
|
||||||
|
uuids.push(uuid)
|
||||||
|
|
||||||
const source = await server.videos.getSource({ id: uuid })
|
const source = await servers[0].videos.getSource({ id: uuid })
|
||||||
expect(source.filename).to.equal(fixture)
|
expect(source.filename).to.equal(fixture)
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests([ server ])
|
this.timeout(60000)
|
||||||
|
|
||||||
|
for (const uuid of uuids) {
|
||||||
|
await servers[0].videos.remove({ id: uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Updating video source', function () {
|
||||||
|
|
||||||
|
describe('Filesystem', function () {
|
||||||
|
|
||||||
|
it('Should replace a video file with transcoding disabled', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await servers[0].config.disableTranscoding()
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'fs without transcoding', fixture: 'video_short_720p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(1)
|
||||||
|
expect(files[0].resolution.id).to.equal(720)
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(1)
|
||||||
|
expect(files[0].resolution.id).to.equal(360)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should replace a video file with transcoding enabled', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const previousPaths: string[] = []
|
||||||
|
|
||||||
|
await servers[0].config.enableTranscoding(true, true, true)
|
||||||
|
|
||||||
|
const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: 'video_short_720p.mp4' })
|
||||||
|
uuid = videoUUID
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
expect(video.inputFileUpdatedAt).to.be.null
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(6 * 2)
|
||||||
|
|
||||||
|
// Grab old paths to ensure we'll regenerate
|
||||||
|
|
||||||
|
previousPaths.push(video.previewPath)
|
||||||
|
previousPaths.push(video.thumbnailPath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
previousPaths.push(file.fileUrl)
|
||||||
|
previousPaths.push(file.torrentUrl)
|
||||||
|
previousPaths.push(file.metadataUrl)
|
||||||
|
|
||||||
|
const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
|
||||||
|
previousPaths.push(JSON.stringify(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storyboards } = await server.storyboard.list({ id: uuid })
|
||||||
|
for (const s of storyboards) {
|
||||||
|
previousPaths.push(s.storyboardPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceDate = new Date()
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
expect(video.inputFileUpdatedAt).to.not.be.null
|
||||||
|
expect(new Date(video.inputFileUpdatedAt)).to.be.above(replaceDate)
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(4 * 2)
|
||||||
|
|
||||||
|
expect(previousPaths).to.not.include(video.previewPath)
|
||||||
|
expect(previousPaths).to.not.include(video.thumbnailPath)
|
||||||
|
|
||||||
|
await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
expect(previousPaths).to.not.include(file.fileUrl)
|
||||||
|
expect(previousPaths).to.not.include(file.torrentUrl)
|
||||||
|
expect(previousPaths).to.not.include(file.metadataUrl)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
|
||||||
|
expect(previousPaths).to.not.include(JSON.stringify(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storyboards } = await server.storyboard.list({ id: uuid })
|
||||||
|
for (const s of storyboards) {
|
||||||
|
expect(previousPaths).to.not.include(s.storyboardPath)
|
||||||
|
|
||||||
|
await makeGetRequest({ url: server.url, path: s.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should have cleaned up old files', async function () {
|
||||||
|
{
|
||||||
|
const count = await servers[0].servers.countFiles('storyboards')
|
||||||
|
expect(count).to.equal(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const count = await servers[0].servers.countFiles('web-videos')
|
||||||
|
expect(count).to.equal(5 + 1) // +1 for private directory
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const count = await servers[0].servers.countFiles('streaming-playlists/hls')
|
||||||
|
expect(count).to.equal(1 + 1) // +1 for private directory
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const count = await servers[0].servers.countFiles('torrents')
|
||||||
|
expect(count).to.equal(9)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should have the correct source input', async function () {
|
||||||
|
const source = await servers[0].videos.getSource({ id: uuid })
|
||||||
|
|
||||||
|
expect(source.filename).to.equal('video_short_360p.mp4')
|
||||||
|
expect(new Date(source.createdAt)).to.be.above(replaceDate)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not have regenerated miniatures that were previously uploaded', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.upload({
|
||||||
|
attributes: {
|
||||||
|
name: 'custom miniatures',
|
||||||
|
thumbnailfile: 'custom-thumbnail.jpg',
|
||||||
|
previewfile: 'custom-preview.jpg'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const previousPaths: string[] = []
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
previousPaths.push(video.previewPath)
|
||||||
|
previousPaths.push(video.thumbnailPath)
|
||||||
|
|
||||||
|
await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
expect(previousPaths).to.include(video.previewPath)
|
||||||
|
expect(previousPaths).to.include(video.thumbnailPath)
|
||||||
|
|
||||||
|
await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Autoblacklist', function () {
|
||||||
|
|
||||||
|
function updateAutoBlacklist (enabled: boolean) {
|
||||||
|
return servers[0].config.updateExistingSubConfig({
|
||||||
|
newConfig: {
|
||||||
|
autoBlacklist: {
|
||||||
|
videos: {
|
||||||
|
ofUsers: {
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectBlacklist (uuid: string, value: boolean) {
|
||||||
|
const video = await servers[0].videos.getWithToken({ id: uuid })
|
||||||
|
|
||||||
|
expect(video.blacklisted).to.equal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await updateAutoBlacklist(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should auto blacklist an unblacklisted video after file replacement', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
await expectBlacklist(uuid, true)
|
||||||
|
|
||||||
|
await servers[0].blacklist.remove({ videoId: uuid })
|
||||||
|
await expectBlacklist(uuid, false)
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await expectBlacklist(uuid, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should auto blacklist an already blacklisted video after file replacement', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
await expectBlacklist(uuid, true)
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await expectBlacklist(uuid, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not auto blacklist if auto blacklist has been disabled between the upload and the replacement', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
await expectBlacklist(uuid, true)
|
||||||
|
|
||||||
|
await servers[0].blacklist.remove({ videoId: uuid })
|
||||||
|
await expectBlacklist(uuid, false)
|
||||||
|
|
||||||
|
await updateAutoBlacklist(false)
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short1.webm' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await expectBlacklist(uuid, false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('With object storage enabled', function () {
|
||||||
|
if (areMockObjectStorageTestsDisabled()) return
|
||||||
|
|
||||||
|
const objectStorage = new ObjectStorageCommand()
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const configOverride = objectStorage.getDefaultMockConfig()
|
||||||
|
await objectStorage.prepareDefaultMockBuckets()
|
||||||
|
|
||||||
|
await servers[0].kill()
|
||||||
|
await servers[0].run(configOverride)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should replace a video file with transcoding disabled', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await servers[0].config.disableTranscoding()
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({
|
||||||
|
name: 'object storage without transcoding',
|
||||||
|
fixture: 'video_short_720p.mp4'
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(1)
|
||||||
|
expect(files[0].resolution.id).to.equal(720)
|
||||||
|
expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(1)
|
||||||
|
expect(files[0].resolution.id).to.equal(360)
|
||||||
|
expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should replace a video file with transcoding enabled', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const previousPaths: string[] = []
|
||||||
|
|
||||||
|
await servers[0].config.enableTranscoding(true, true, true)
|
||||||
|
|
||||||
|
const { uuid: videoUUID } = await servers[0].videos.quickUpload({
|
||||||
|
name: 'object storage with transcoding',
|
||||||
|
fixture: 'video_short_360p.mp4'
|
||||||
|
})
|
||||||
|
uuid = videoUUID
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(4 * 2)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
previousPaths.push(file.fileUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of video.files) {
|
||||||
|
expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of video.streamingPlaylists[0].files) {
|
||||||
|
expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_240p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const video = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const files = getAllFiles(video)
|
||||||
|
expect(files).to.have.lengthOf(3 * 2)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
expect(previousPaths).to.not.include(file.fileUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of video.files) {
|
||||||
|
expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of video.streamingPlaylists[0].files) {
|
||||||
|
expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests(servers)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -19,12 +19,6 @@ import {
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@shared/server-commands'
|
} from '@shared/server-commands'
|
||||||
|
|
||||||
async function countFiles (server: PeerTubeServer, directory: string) {
|
|
||||||
const files = await readdir(server.servers.buildDirectory(directory))
|
|
||||||
|
|
||||||
return files.length
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertNotExists (server: PeerTubeServer, directory: string, substring: string) {
|
async function assertNotExists (server: PeerTubeServer, directory: string, substring: string) {
|
||||||
const files = await readdir(server.servers.buildDirectory(directory))
|
const files = await readdir(server.servers.buildDirectory(directory))
|
||||||
|
|
||||||
|
@ -35,28 +29,28 @@ async function assertNotExists (server: PeerTubeServer, directory: string, subst
|
||||||
|
|
||||||
async function assertCountAreOkay (servers: PeerTubeServer[]) {
|
async function assertCountAreOkay (servers: PeerTubeServer[]) {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
const videosCount = await countFiles(server, 'web-videos')
|
const videosCount = await server.servers.countFiles('web-videos')
|
||||||
expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory
|
expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory
|
||||||
|
|
||||||
const privateVideosCount = await countFiles(server, 'web-videos/private')
|
const privateVideosCount = await server.servers.countFiles('web-videos/private')
|
||||||
expect(privateVideosCount).to.equal(4)
|
expect(privateVideosCount).to.equal(4)
|
||||||
|
|
||||||
const torrentsCount = await countFiles(server, 'torrents')
|
const torrentsCount = await server.servers.countFiles('torrents')
|
||||||
expect(torrentsCount).to.equal(24)
|
expect(torrentsCount).to.equal(24)
|
||||||
|
|
||||||
const previewsCount = await countFiles(server, 'previews')
|
const previewsCount = await server.servers.countFiles('previews')
|
||||||
expect(previewsCount).to.equal(3)
|
expect(previewsCount).to.equal(3)
|
||||||
|
|
||||||
const thumbnailsCount = await countFiles(server, 'thumbnails')
|
const thumbnailsCount = await server.servers.countFiles('thumbnails')
|
||||||
expect(thumbnailsCount).to.equal(5) // 3 local videos, 1 local playlist, 2 remotes videos (lazy downloaded) and 1 remote playlist
|
expect(thumbnailsCount).to.equal(5) // 3 local videos, 1 local playlist, 2 remotes videos (lazy downloaded) and 1 remote playlist
|
||||||
|
|
||||||
const avatarsCount = await countFiles(server, 'avatars')
|
const avatarsCount = await server.servers.countFiles('avatars')
|
||||||
expect(avatarsCount).to.equal(4)
|
expect(avatarsCount).to.equal(4)
|
||||||
|
|
||||||
const hlsRootCount = await countFiles(server, join('streaming-playlists', 'hls'))
|
const hlsRootCount = await server.servers.countFiles(join('streaming-playlists', 'hls'))
|
||||||
expect(hlsRootCount).to.equal(3) // 2 videos + private directory
|
expect(hlsRootCount).to.equal(3) // 2 videos + private directory
|
||||||
|
|
||||||
const hlsPrivateRootCount = await countFiles(server, join('streaming-playlists', 'hls', 'private'))
|
const hlsPrivateRootCount = await server.servers.countFiles(join('streaming-playlists', 'hls', 'private'))
|
||||||
expect(hlsPrivateRootCount).to.equal(1)
|
expect(hlsPrivateRootCount).to.equal(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -277,7 +277,7 @@ function checkUploadVideoParam (
|
||||||
) {
|
) {
|
||||||
return mode === 'legacy'
|
return mode === 'legacy'
|
||||||
? server.videos.buildLegacyUpload({ token, attributes, expectedStatus })
|
? server.videos.buildLegacyUpload({ token, attributes, expectedStatus })
|
||||||
: server.videos.buildResumeUpload({ token, attributes, expectedStatus })
|
: server.videos.buildResumeUpload({ token, attributes, expectedStatus, path: '/api/v1/videos/upload-resumable' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverNumber starts from 1
|
// serverNumber starts from 1
|
||||||
|
|
|
@ -86,13 +86,15 @@ declare module 'express' {
|
||||||
// Our custom UploadXFile object using our custom metadata
|
// Our custom UploadXFile object using our custom metadata
|
||||||
export type CustomUploadXFile <T extends Metadata> = UploadXFile & { metadata: T }
|
export type CustomUploadXFile <T extends Metadata> = UploadXFile & { metadata: T }
|
||||||
|
|
||||||
export type EnhancedUploadXFile = CustomUploadXFile<UploadXFileMetadata> & {
|
export type EnhancedUploadXFile = CustomUploadXFile<Metadata> & {
|
||||||
duration: number
|
duration: number
|
||||||
path: string
|
path: string
|
||||||
filename: string
|
filename: string
|
||||||
originalname: string
|
originalname: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UploadNewVideoUploadXFile = EnhancedUploadXFile & CustomUploadXFile<UploadXFileMetadata>
|
||||||
|
|
||||||
// Extends Response with added functions and potential variables passed by middlewares
|
// Extends Response with added functions and potential variables passed by middlewares
|
||||||
interface Response {
|
interface Response {
|
||||||
fail: (options: {
|
fail: (options: {
|
||||||
|
@ -139,7 +141,8 @@ declare module 'express' {
|
||||||
|
|
||||||
videoFile?: MVideoFile
|
videoFile?: MVideoFile
|
||||||
|
|
||||||
videoFileResumable?: EnhancedUploadXFile
|
uploadVideoFileResumable?: UploadNewVideoUploadXFile
|
||||||
|
updateVideoFileResumable?: EnhancedUploadXFile
|
||||||
|
|
||||||
videoImport?: MVideoImportDefault
|
videoImport?: MVideoImportDefault
|
||||||
|
|
||||||
|
|
|
@ -31,9 +31,11 @@ export interface VideoObject {
|
||||||
downloadEnabled: boolean
|
downloadEnabled: boolean
|
||||||
waitTranscoding: boolean
|
waitTranscoding: boolean
|
||||||
state: VideoState
|
state: VideoState
|
||||||
|
|
||||||
published: string
|
published: string
|
||||||
originallyPublishedAt: string
|
originallyPublishedAt: string
|
||||||
updated: string
|
updated: string
|
||||||
|
uploadDate: string
|
||||||
|
|
||||||
mediaType: 'text/markdown'
|
mediaType: 'text/markdown'
|
||||||
content: string
|
content: string
|
||||||
|
|
|
@ -64,6 +64,7 @@ export const serverFilterHookObject = {
|
||||||
'filter:api.video.pre-import-torrent.accept.result': true,
|
'filter:api.video.pre-import-torrent.accept.result': true,
|
||||||
'filter:api.video.post-import-url.accept.result': true,
|
'filter:api.video.post-import-url.accept.result': true,
|
||||||
'filter:api.video.post-import-torrent.accept.result': true,
|
'filter:api.video.post-import-torrent.accept.result': true,
|
||||||
|
'filter:api.video.update-file.accept.result': true,
|
||||||
// Filter the result of the accept comment (thread or reply) functions
|
// Filter the result of the accept comment (thread or reply) functions
|
||||||
// If the functions return false then the user cannot post its comment
|
// If the functions return false then the user cannot post its comment
|
||||||
'filter:api.video-thread.create.accept.result': true,
|
'filter:api.video-thread.create.accept.result': true,
|
||||||
|
@ -155,6 +156,9 @@ export const serverActionHookObject = {
|
||||||
// Fired when a local video is viewed
|
// Fired when a local video is viewed
|
||||||
'action:api.video.viewed': true,
|
'action:api.video.viewed': true,
|
||||||
|
|
||||||
|
// Fired when a local video file has been replaced by a new one
|
||||||
|
'action:api.video.file-updated': true,
|
||||||
|
|
||||||
// Fired when a video channel is created
|
// Fired when a video channel is created
|
||||||
'action:api.video-channel.created': true,
|
'action:api.video-channel.created': true,
|
||||||
// Fired when a video channel is updated
|
// Fired when a video channel is updated
|
||||||
|
|
|
@ -175,6 +175,12 @@ export interface CustomConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: number
|
concurrency: number
|
||||||
|
|
|
@ -192,6 +192,12 @@ export interface ServerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
http: {
|
http: {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export interface VideoSource {
|
export interface VideoSource {
|
||||||
filename: string
|
filename: string
|
||||||
|
createdAt: string | Date
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,4 +94,6 @@ export interface VideoDetails extends Video {
|
||||||
|
|
||||||
files: VideoFile[]
|
files: VideoFile[]
|
||||||
streamingPlaylists: VideoStreamingPlaylist[]
|
streamingPlaylists: VideoStreamingPlaylist[]
|
||||||
|
|
||||||
|
inputFileUpdatedAt: string | Date
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,28 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
disableFileUpdate () {
|
||||||
|
return this.setFileUpdateEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
enableFileUpdate () {
|
||||||
|
return this.setFileUpdateEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFileUpdateEnabled (enabled: boolean) {
|
||||||
|
return this.updateExistingSubConfig({
|
||||||
|
newConfig: {
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
enableChannelSync () {
|
enableChannelSync () {
|
||||||
return this.setChannelSyncEnabled(true)
|
return this.setChannelSyncEnabled(true)
|
||||||
}
|
}
|
||||||
|
@ -466,6 +488,11 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
enabled: false
|
enabled: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoFile: {
|
||||||
|
update: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { exec } from 'child_process'
|
import { exec } from 'child_process'
|
||||||
import { copy, ensureDir, readFile, remove } from 'fs-extra'
|
import { copy, ensureDir, readFile, readdir, remove } from 'fs-extra'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { isGithubCI, root, wait } from '@shared/core-utils'
|
import { isGithubCI, root, wait } from '@shared/core-utils'
|
||||||
import { getFileSize } from '@shared/extra-utils'
|
import { getFileSize } from '@shared/extra-utils'
|
||||||
|
@ -77,6 +77,12 @@ export class ServersCommand extends AbstractCommand {
|
||||||
return join(root(), 'test' + this.server.internalServerNumber, directory)
|
return join(root(), 'test' + this.server.internalServerNumber, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countFiles (directory: string) {
|
||||||
|
const files = await readdir(this.buildDirectory(directory))
|
||||||
|
|
||||||
|
return files.length
|
||||||
|
}
|
||||||
|
|
||||||
buildWebVideoFilePath (fileUrl: string) {
|
buildWebVideoFilePath (fileUrl: string) {
|
||||||
return this.buildDirectory(join('web-videos', basename(fileUrl)))
|
return this.buildDirectory(join('web-videos', basename(fileUrl)))
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VideosCommand extends AbstractCommand {
|
export class VideosCommand extends AbstractCommand {
|
||||||
|
|
||||||
getCategories (options: OverrideCommandOptions = {}) {
|
getCategories (options: OverrideCommandOptions = {}) {
|
||||||
const path = '/api/v1/videos/categories'
|
const path = '/api/v1/videos/categories'
|
||||||
|
|
||||||
|
@ -424,7 +425,7 @@ export class VideosCommand extends AbstractCommand {
|
||||||
|
|
||||||
const created = mode === 'legacy'
|
const created = mode === 'legacy'
|
||||||
? await this.buildLegacyUpload({ ...options, attributes })
|
? await this.buildLegacyUpload({ ...options, attributes })
|
||||||
: await this.buildResumeUpload({ ...options, attributes })
|
: await this.buildResumeUpload({ ...options, path: '/api/v1/videos/upload-resumable', attributes })
|
||||||
|
|
||||||
// Wait torrent generation
|
// Wait torrent generation
|
||||||
const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
|
const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
@ -458,9 +459,10 @@ export class VideosCommand extends AbstractCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildResumeUpload (options: OverrideCommandOptions & {
|
async buildResumeUpload (options: OverrideCommandOptions & {
|
||||||
attributes: VideoEdit
|
path: string
|
||||||
|
attributes: { fixture?: string } & { [id: string]: any }
|
||||||
}): Promise<VideoCreateResult> {
|
}): Promise<VideoCreateResult> {
|
||||||
const { attributes, expectedStatus } = options
|
const { path, attributes, expectedStatus } = options
|
||||||
|
|
||||||
let size = 0
|
let size = 0
|
||||||
let videoFilePath: string
|
let videoFilePath: string
|
||||||
|
@ -478,7 +480,15 @@ export class VideosCommand extends AbstractCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not check status automatically, we'll check it manually
|
// Do not check status automatically, we'll check it manually
|
||||||
const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
|
const initializeSessionRes = await this.prepareResumableUpload({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
expectedStatus: null,
|
||||||
|
attributes,
|
||||||
|
size,
|
||||||
|
mimetype
|
||||||
|
})
|
||||||
const initStatus = initializeSessionRes.status
|
const initStatus = initializeSessionRes.status
|
||||||
|
|
||||||
if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
|
if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
|
||||||
|
@ -487,10 +497,23 @@ export class VideosCommand extends AbstractCommand {
|
||||||
|
|
||||||
const pathUploadId = locationHeader.split('?')[1]
|
const pathUploadId = locationHeader.split('?')[1]
|
||||||
|
|
||||||
const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
|
const result = await this.sendResumableChunks({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
pathUploadId,
|
||||||
|
videoFilePath,
|
||||||
|
size
|
||||||
|
})
|
||||||
|
|
||||||
if (result.statusCode === HttpStatusCode.OK_200) {
|
if (result.statusCode === HttpStatusCode.OK_200) {
|
||||||
await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
|
await this.endResumableUpload({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
expectedStatus: HttpStatusCode.NO_CONTENT_204,
|
||||||
|
path,
|
||||||
|
pathUploadId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.body?.video || result.body as any
|
return result.body?.video || result.body as any
|
||||||
|
@ -506,18 +529,19 @@ export class VideosCommand extends AbstractCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareResumableUpload (options: OverrideCommandOptions & {
|
async prepareResumableUpload (options: OverrideCommandOptions & {
|
||||||
attributes: VideoEdit
|
path: string
|
||||||
|
attributes: { fixture?: string } & { [id: string]: any }
|
||||||
size: number
|
size: number
|
||||||
mimetype: string
|
mimetype: string
|
||||||
|
|
||||||
originalName?: string
|
originalName?: string
|
||||||
lastModified?: number
|
lastModified?: number
|
||||||
}) {
|
}) {
|
||||||
const { attributes, originalName, lastModified, size, mimetype } = options
|
const { path, attributes, originalName, lastModified, size, mimetype } = options
|
||||||
|
|
||||||
const path = '/api/v1/videos/upload-resumable'
|
const attaches = this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ]))
|
||||||
|
|
||||||
return this.postUploadRequest({
|
const uploadOptions = {
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
path,
|
path,
|
||||||
|
@ -538,11 +562,16 @@ export class VideosCommand extends AbstractCommand {
|
||||||
implicitToken: true,
|
implicitToken: true,
|
||||||
|
|
||||||
defaultExpectedStatus: null
|
defaultExpectedStatus: null
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (Object.keys(attaches).length === 0) return this.postBodyRequest(uploadOptions)
|
||||||
|
|
||||||
|
return this.postUploadRequest(uploadOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendResumableChunks (options: OverrideCommandOptions & {
|
sendResumableChunks (options: OverrideCommandOptions & {
|
||||||
pathUploadId: string
|
pathUploadId: string
|
||||||
|
path: string
|
||||||
videoFilePath: string
|
videoFilePath: string
|
||||||
size: number
|
size: number
|
||||||
contentLength?: number
|
contentLength?: number
|
||||||
|
@ -550,6 +579,7 @@ export class VideosCommand extends AbstractCommand {
|
||||||
digestBuilder?: (chunk: any) => string
|
digestBuilder?: (chunk: any) => string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
path,
|
||||||
pathUploadId,
|
pathUploadId,
|
||||||
videoFilePath,
|
videoFilePath,
|
||||||
size,
|
size,
|
||||||
|
@ -559,7 +589,6 @@ export class VideosCommand extends AbstractCommand {
|
||||||
expectedStatus = HttpStatusCode.OK_200
|
expectedStatus = HttpStatusCode.OK_200
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const path = '/api/v1/videos/upload-resumable'
|
|
||||||
let start = 0
|
let start = 0
|
||||||
|
|
||||||
const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
|
const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
|
||||||
|
@ -610,12 +639,13 @@ export class VideosCommand extends AbstractCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
endResumableUpload (options: OverrideCommandOptions & {
|
endResumableUpload (options: OverrideCommandOptions & {
|
||||||
|
path: string
|
||||||
pathUploadId: string
|
pathUploadId: string
|
||||||
}) {
|
}) {
|
||||||
return this.deleteRequest({
|
return this.deleteRequest({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
path: '/api/v1/videos/upload-resumable',
|
path: options.path,
|
||||||
rawQuery: options.pathUploadId,
|
rawQuery: options.pathUploadId,
|
||||||
implicitToken: true,
|
implicitToken: true,
|
||||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
|
@ -657,6 +687,21 @@ export class VideosCommand extends AbstractCommand {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
replaceSourceFile (options: OverrideCommandOptions & {
|
||||||
|
videoId: number | string
|
||||||
|
fixture: string
|
||||||
|
}) {
|
||||||
|
return this.buildResumeUpload({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable',
|
||||||
|
attributes: { fixture: options.fixture },
|
||||||
|
expectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
removeHLSPlaylist (options: OverrideCommandOptions & {
|
removeHLSPlaylist (options: OverrideCommandOptions & {
|
||||||
videoId: number | string
|
videoId: number | string
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -2641,22 +2641,6 @@ paths:
|
||||||
example: |
|
example: |
|
||||||
**[Want to help to translate this video?](https://weblate.framasoft.org/projects/what-is-peertube-video/)**\r\n\r\n**Take back the control of your videos! [#JoinPeertube](https://joinpeertube.org)**
|
**[Want to help to translate this video?](https://weblate.framasoft.org/projects/what-is-peertube-video/)**\r\n\r\n**Take back the control of your videos! [#JoinPeertube](https://joinpeertube.org)**
|
||||||
|
|
||||||
'/api/v1/videos/{id}/source':
|
|
||||||
post:
|
|
||||||
summary: Get video source file metadata
|
|
||||||
operationId: getVideoSource
|
|
||||||
tags:
|
|
||||||
- Video
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/idOrUUID'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: successful operation
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/VideoSource'
|
|
||||||
|
|
||||||
'/api/v1/videos/{id}/views':
|
'/api/v1/videos/{id}/views':
|
||||||
post:
|
post:
|
||||||
summary: Notify user is watching a video
|
summary: Notify user is watching a video
|
||||||
|
@ -2871,21 +2855,8 @@ paths:
|
||||||
- Video
|
- Video
|
||||||
- Video Upload
|
- Video Upload
|
||||||
parameters:
|
parameters:
|
||||||
- name: X-Upload-Content-Length
|
- $ref: '#/components/parameters/resumableUploadInitContentLengthHeader'
|
||||||
in: header
|
- $ref: '#/components/parameters/resumableUploadInitContentTypeHeader'
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
example: 2469036
|
|
||||||
required: true
|
|
||||||
description: Number of bytes that will be uploaded in subsequent requests. Set this value to the size of the file you are uploading.
|
|
||||||
- name: X-Upload-Content-Type
|
|
||||||
in: header
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: mimetype
|
|
||||||
example: video/mp4
|
|
||||||
required: true
|
|
||||||
description: MIME type of the file that you are uploading. Depending on your instance settings, acceptable values might vary.
|
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
|
@ -2924,36 +2895,9 @@ paths:
|
||||||
- Video
|
- Video
|
||||||
- Video Upload
|
- Video Upload
|
||||||
parameters:
|
parameters:
|
||||||
- name: upload_id
|
- $ref: '#/components/parameters/resumableUploadId'
|
||||||
in: query
|
- $ref: '#/components/parameters/resumableUploadChunkContentRangeHeader'
|
||||||
required: true
|
- $ref: '#/components/parameters/resumableUploadChunkContentLengthHeader'
|
||||||
description: |
|
|
||||||
Created session id to proceed with. If you didn't send chunks in the last hour, it is
|
|
||||||
not valid anymore and you need to initialize a new upload.
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: Content-Range
|
|
||||||
in: header
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
example: bytes 0-262143/2469036
|
|
||||||
required: true
|
|
||||||
description: |
|
|
||||||
Specifies the bytes in the file that the request is uploading.
|
|
||||||
|
|
||||||
For example, a value of `bytes 0-262143/1000000` shows that the request is sending the first
|
|
||||||
262144 bytes (256 x 1024) in a 2,469,036 byte file.
|
|
||||||
- name: Content-Length
|
|
||||||
in: header
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
example: 262144
|
|
||||||
required: true
|
|
||||||
description: |
|
|
||||||
Size of the chunk that the request is sending.
|
|
||||||
|
|
||||||
Remember that larger chunks are more efficient. PeerTube's web client uses chunks varying from
|
|
||||||
1048576 bytes (~1MB) and increases or reduces size depending on connection health.
|
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/octet-stream:
|
application/octet-stream:
|
||||||
|
@ -3009,14 +2953,7 @@ paths:
|
||||||
- Video
|
- Video
|
||||||
- Video Upload
|
- Video Upload
|
||||||
parameters:
|
parameters:
|
||||||
- name: upload_id
|
- $ref: '#/components/parameters/resumableUploadId'
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
description: |
|
|
||||||
Created session id to proceed with. If you didn't send chunks in the last 12 hours, it is
|
|
||||||
not valid anymore and the upload session has already been deleted with its data ;-)
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: Content-Length
|
- name: Content-Length
|
||||||
in: header
|
in: header
|
||||||
required: true
|
required: true
|
||||||
|
@ -3286,6 +3223,140 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/LiveVideoSessionResponse'
|
$ref: '#/components/schemas/LiveVideoSessionResponse'
|
||||||
|
|
||||||
|
'/api/v1/videos/{id}/source':
|
||||||
|
get:
|
||||||
|
summary: Get video source file metadata
|
||||||
|
operationId: getVideoSource
|
||||||
|
tags:
|
||||||
|
- Video
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/idOrUUID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VideoSource'
|
||||||
|
|
||||||
|
'/api/v1/videos/{id}/source/replace-resumable':
|
||||||
|
post:
|
||||||
|
summary: Initialize the resumable replacement of a video
|
||||||
|
description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video
|
||||||
|
operationId: replaceVideoSourceResumableInit
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
tags:
|
||||||
|
- Video
|
||||||
|
- Video Upload
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/resumableUploadInitContentLengthHeader'
|
||||||
|
- $ref: '#/components/parameters/resumableUploadInitContentTypeHeader'
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VideoReplaceSourceRequestResumable'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: file already exists, send a [`resume`](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) request instead
|
||||||
|
'201':
|
||||||
|
description: created
|
||||||
|
headers:
|
||||||
|
Location:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: url
|
||||||
|
Content-Length:
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 0
|
||||||
|
'413':
|
||||||
|
x-summary: video file too large, due to quota, absolute max file size or concurrent partial upload limit
|
||||||
|
description: |
|
||||||
|
Disambiguate via `type`:
|
||||||
|
- `max_file_size_reached` for the absolute file size limit
|
||||||
|
- `quota_reached` for quota limits whether daily or global
|
||||||
|
'415':
|
||||||
|
description: video type unsupported
|
||||||
|
put:
|
||||||
|
summary: Send chunk for the resumable replacement of a video
|
||||||
|
description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the replacement of a video
|
||||||
|
operationId: replaceVideoSourceResumable
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
tags:
|
||||||
|
- Video
|
||||||
|
- Video Upload
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/resumableUploadId'
|
||||||
|
- $ref: '#/components/parameters/resumableUploadChunkContentRangeHeader'
|
||||||
|
- $ref: '#/components/parameters/resumableUploadChunkContentLengthHeader'
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: 'last chunk received: successful operation'
|
||||||
|
'308':
|
||||||
|
description: resume incomplete
|
||||||
|
headers:
|
||||||
|
Range:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: bytes=0-262143
|
||||||
|
Content-Length:
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 0
|
||||||
|
'403':
|
||||||
|
description: video didn't pass file replacement filter
|
||||||
|
'404':
|
||||||
|
description: replace upload not found
|
||||||
|
'409':
|
||||||
|
description: chunk doesn't match range
|
||||||
|
'422':
|
||||||
|
description: video unreadable
|
||||||
|
'429':
|
||||||
|
description: too many concurrent requests
|
||||||
|
'503':
|
||||||
|
description: upload is already being processed
|
||||||
|
headers:
|
||||||
|
'Retry-After':
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 300
|
||||||
|
delete:
|
||||||
|
summary: Cancel the resumable replacement of a video
|
||||||
|
description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video
|
||||||
|
operationId: replaceVideoSourceResumableCancel
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
tags:
|
||||||
|
- Video
|
||||||
|
- Video Upload
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/resumableUploadId'
|
||||||
|
- name: Content-Length
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 0
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: source file replacement cancelled
|
||||||
|
headers:
|
||||||
|
Content-Length:
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 0
|
||||||
|
'404':
|
||||||
|
description: source file replacement not found
|
||||||
|
|
||||||
/api/v1/users/me/abuses:
|
/api/v1/users/me/abuses:
|
||||||
get:
|
get:
|
||||||
summary: List my abuses
|
summary: List my abuses
|
||||||
|
@ -6640,6 +6711,58 @@ components:
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
resumableUploadInitContentLengthHeader:
|
||||||
|
name: X-Upload-Content-Length
|
||||||
|
in: header
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 2469036
|
||||||
|
required: true
|
||||||
|
description: Number of bytes that will be uploaded in subsequent requests. Set this value to the size of the file you are uploading.
|
||||||
|
resumableUploadInitContentTypeHeader:
|
||||||
|
name: X-Upload-Content-Type
|
||||||
|
in: header
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: mimetype
|
||||||
|
example: video/mp4
|
||||||
|
required: true
|
||||||
|
description: MIME type of the file that you are uploading. Depending on your instance settings, acceptable values might vary.
|
||||||
|
resumableUploadChunkContentRangeHeader:
|
||||||
|
name: Content-Range
|
||||||
|
in: header
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: bytes 0-262143/2469036
|
||||||
|
required: true
|
||||||
|
description: |
|
||||||
|
Specifies the bytes in the file that the request is uploading.
|
||||||
|
|
||||||
|
For example, a value of `bytes 0-262143/1000000` shows that the request is sending the first
|
||||||
|
262144 bytes (256 x 1024) in a 2,469,036 byte file.
|
||||||
|
resumableUploadChunkContentLengthHeader:
|
||||||
|
name: Content-Length
|
||||||
|
in: header
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 262144
|
||||||
|
required: true
|
||||||
|
description: |
|
||||||
|
Size of the chunk that the request is sending.
|
||||||
|
|
||||||
|
Remember that larger chunks are more efficient. PeerTube's web client uses chunks varying from
|
||||||
|
1048576 bytes (~1MB) and increases or reduces size depending on connection health.
|
||||||
|
resumableUploadId:
|
||||||
|
name: upload_id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: |
|
||||||
|
Created session id to proceed with. If you didn't send chunks in the last hour, it is
|
||||||
|
not valid anymore and you need to initialize a new upload.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
OAuth2:
|
OAuth2:
|
||||||
description: |
|
description: |
|
||||||
|
@ -7209,6 +7332,11 @@ components:
|
||||||
type: boolean
|
type: boolean
|
||||||
downloadEnabled:
|
downloadEnabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
inputFileUpdatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: Latest input file update. Null if the file has never been replaced since the original upload
|
||||||
trackerUrls:
|
trackerUrls:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
@ -7554,6 +7682,9 @@ components:
|
||||||
properties:
|
properties:
|
||||||
filename:
|
filename:
|
||||||
type: string
|
type: string
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
ActorImage:
|
ActorImage:
|
||||||
properties:
|
properties:
|
||||||
path:
|
path:
|
||||||
|
@ -8403,6 +8534,13 @@ components:
|
||||||
$ref: '#/components/schemas/Video/properties/uuid'
|
$ref: '#/components/schemas/Video/properties/uuid'
|
||||||
shortUUID:
|
shortUUID:
|
||||||
$ref: '#/components/schemas/Video/properties/shortUUID'
|
$ref: '#/components/schemas/Video/properties/shortUUID'
|
||||||
|
VideoReplaceSourceRequestResumable:
|
||||||
|
properties:
|
||||||
|
filename:
|
||||||
|
description: Video filename including extension
|
||||||
|
type: string
|
||||||
|
format: filename
|
||||||
|
example: what_is_peertube.mp4
|
||||||
CommentThreadResponse:
|
CommentThreadResponse:
|
||||||
properties:
|
properties:
|
||||||
total:
|
total:
|
||||||
|
|
Loading…
Reference in New Issue