Add ability to list and delete original file

In admin
pull/6294/head
Chocobozzz 2024-03-26 14:05:19 +01:00
parent 058ef6912c
commit a159b8b517
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
21 changed files with 295 additions and 45 deletions

View File

@ -112,7 +112,8 @@ export class VideoAdminService {
let include = VideoInclude.BLACKLISTED |
VideoInclude.BLOCKED_OWNER |
VideoInclude.NOT_PUBLISHED_STATE |
VideoInclude.FILES
VideoInclude.FILES |
VideoInclude.SOURCE
let privacyOneOf = getAllPrivacies()

View File

@ -70,14 +70,17 @@
</td>
<td>
<span class="pt-badge badge-blue" *ngIf="video.isLocal">Local</span>
<span class="pt-badge badge-purple" *ngIf="!video.isLocal">Remote</span>
@if (video.isLocal) {
<span class="pt-badge badge-blue" i18n>Local</span>
} @else {
<span class="pt-badge badge-purple" i18n>Remote</span>
}
<span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span>
<span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
<span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow" i18n>{{ video.state.label }}</span>
<span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>
<span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span>
<span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span>
@ -86,10 +89,11 @@
</td>
<td>
<span *ngIf="hasHLS(video)" class="pt-badge badge-blue">HLS</span>
<span *ngIf="hasWebVideos(video)" class="pt-badge badge-blue">Web Videos ({{ video.files.length }})</span>
<span i18n *ngIf="video.isLive" class="pt-badge badge-blue">Live</span>
<span i18n *ngIf="hasObjectStorage(video)" class="pt-badge badge-purple">Object storage</span>
<span *ngIf="hasOriginalFile(video)" class="pt-badge badge-blue" i18n>Original file</span>
<span *ngIf="hasHLS(video)" class="pt-badge badge-blue" i18n>HLS</span>
<span *ngIf="hasWebVideos(video)" class="pt-badge badge-blue" i18n>Web Videos ({{ video.files.length }})</span>
<span *ngIf="video.isLive" class="pt-badge badge-blue" i18n>Live</span>
<span *ngIf="hasObjectStorage(video)" class="pt-badge badge-purple" i18n>Object storage</span>
<span *ngIf="!isImport(video) && !video.isLive && video.isLocal">{{ getFilesSize(video) | bytes: 1 }}</span>
</td>
@ -105,8 +109,26 @@
<tr>
<td class="video-info expand-cell" myAutoColspan>
<div>
<div *ngIf="hasWebVideos(video)">
Web Videos:
<div class="me-3" *ngIf="hasOriginalFile(video)">
<ng-container i18n>Original file:</ng-container>
<ul>
<li>
{{ video.videoSource.inputFilename }}: {{ video.videoSource.size | bytes: 1 }}
<button
*ngIf="canRemoveOneFile(video)" class="border-0 p-0"
i18n-title title="Delete this file"
(click)="removeVideoSourceFile(video)"
>
<my-global-icon iconName="delete"></my-global-icon>
</button>
</li>
</ul>
</div>
<div class="me-3" *ngIf="hasWebVideos(video)">
<ng-container i18n>Web Videos:</ng-container>
<ul>
<li *ngFor="let file of video.files">
@ -124,7 +146,7 @@
</div>
<div *ngIf="hasHLS(video)">
HLS:
<ng-container i18n>HLS:</ng-container>
<ul>
<li *ngFor="let file of video.streamingPlaylists[0].files">

View File

@ -1,32 +1,32 @@
import { SortMeta, SharedModule } from 'primeng/api'
import { finalize } from 'rxjs/operators'
import { DatePipe, NgClass, NgFor, NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { formatICU, getAbsoluteAPIUrl } from '@app/helpers'
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.component'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { getAllFiles } from '@peertube/peertube-core-utils'
import { UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { VideoAdminService } from './video-admin.service'
import { BytesPipe } from '../../../shared/shared-main/angular/bytes.pipe'
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { finalize } from 'rxjs/operators'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { AutoColspanDirective } from '../../../shared/shared-main/angular/auto-colspan.directive'
import { BytesPipe } from '../../../shared/shared-main/angular/bytes.pipe'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component'
import {
VideoActionsDisplayType,
VideoActionsDropdownComponent
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { NgClass, NgIf, NgFor, DatePipe } from '@angular/common'
import { TableModule } from 'primeng/table'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.component'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { VideoAdminService } from './video-admin.service'
@Component({
selector: 'my-video-list',
@ -187,6 +187,10 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
return video.state.id === VideoState.TO_IMPORT
}
hasOriginalFile (video: Video) {
return !!video.videoSource?.fileDownloadUrl
}
hasHLS (video: Video) {
const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
if (!p) return false
@ -211,13 +215,10 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
}
getFilesSize (video: Video) {
let files = video.files
let total = getAllFiles(video).reduce((p, f) => p += f.size, 0)
total += video.videoSource?.size || 0
if (this.hasHLS(video)) {
files = files.concat(video.streamingPlaylists[0].files)
}
return files.reduce((p, f) => p += f.size, 0)
return total
}
async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'web-videos') {
@ -236,6 +237,22 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
})
}
async removeVideoSourceFile (video: Video) {
const message = $localize`Are you sure you want to delete the original file of this video?`
const res = await this.confirmService.confirm(message, $localize`Delete original file`)
if (res === false) return
this.videoService.removeSourceFile(video.uuid)
.subscribe({
next: () => {
this.notifier.success($localize`Original file removed.`)
this.reloadData()
},
error: err => this.notifier.error(err.message)
})
}
protected reloadDataInternal () {
this.loading = true

View File

@ -16,7 +16,8 @@ import {
VideoState,
VideoStateType,
VideoStreamingPlaylist,
VideoStreamingPlaylistType
VideoStreamingPlaylistType,
VideoSource
} from '@peertube/peertube-models'
export class Video implements VideoServerModel {
@ -111,6 +112,8 @@ export class Video implements VideoServerModel {
streamingPlaylists?: VideoStreamingPlaylist[]
files?: VideoFile[]
videoSource?: VideoSource
static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
}
@ -192,6 +195,7 @@ export class Video implements VideoServerModel {
this.streamingPlaylists = hash.streamingPlaylists
this.files = hash.files
this.videoSource = hash.videoSource
this.userHistory = hash.userHistory

View File

@ -326,6 +326,11 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
removeSourceFile (videoId: number | string) {
return this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source/file')
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
runTranscoding (options: {
videoIds: (number | string)[]
type: 'hls' | 'web-video'

View File

@ -4,7 +4,8 @@ export const VideoInclude = {
BLACKLISTED: 1 << 1,
BLOCKED_OWNER: 1 << 2,
FILES: 1 << 3,
CAPTIONS: 1 << 4
CAPTIONS: 1 << 4,
SOURCE: 1 << 5
} as const
export type VideoIncludeType = typeof VideoInclude[keyof typeof VideoInclude]

View File

@ -4,6 +4,7 @@ import { VideoFile } from './file/index.js'
import { VideoConstant } from './video-constant.model.js'
import { VideoPrivacyType } from './video-privacy.enum.js'
import { VideoScheduleUpdate } from './video-schedule-update.model.js'
import { VideoSource } from './video-source.model.js'
import { VideoStateType } from './video-state.enum.js'
import { VideoStreamingPlaylist } from './video-streaming-playlist.model.js'
@ -75,6 +76,8 @@ export interface VideoAdditionalAttributes {
files: VideoFile[]
streamingPlaylists: VideoStreamingPlaylist[]
videoSource: VideoSource
}
export interface VideoDetails extends Video {

View File

@ -183,6 +183,20 @@ export class VideosCommand extends AbstractCommand {
})
}
deleteSource (options: OverrideCommandOptions & {
id: number | string
}) {
const path = '/api/v1/videos/' + options.id + '/source/file'
return this.deleteRequest({
...options,
path,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
async getId (options: OverrideCommandOptions & {
uuid: number | string
}) {
@ -273,12 +287,12 @@ export class VideosCommand extends AbstractCommand {
const privacyOneOf = getAllPrivacies()
return this.list({
...options,
include,
nsfw,
privacyOneOf,
...options,
token: this.buildCommonRequestToken({ ...options, implicitToken: true })
})
}

View File

@ -209,6 +209,38 @@ describe('Test video sources API validator', function () {
})
})
describe('When deleting video source file', function () {
let userAccessToken: string
let videoId: string
before(async function () {
userAccessToken = await server.users.generateUserAndToken('user56')
await server.config.enableMinimumTranscoding({ keepOriginal: true })
const { uuid } = await server.videos.quickUpload({ name: 'with source' })
videoId = uuid
await waitJobs([ server ])
})
it('Should fail without token', async function () {
await server.videos.deleteSource({ id: videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with another user', async function () {
await server.videos.deleteSource({ id: videoId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with an unknown video', async function () {
await server.videos.deleteSource({ id: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should succeed with the correct params', async function () {
await server.videos.deleteSource({ id: videoId })
})
})
after(async function () {
await cleanupTests([ server ])
})

View File

@ -48,7 +48,8 @@ describe('Test video filters validators', function () {
const validIncludes = [
VideoInclude.NONE,
VideoInclude.BLOCKED_OWNER,
VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED
VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED,
VideoInclude.SOURCE
]
async function testEndpoints (options: {

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { getAllFiles } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import { HttpStatusCode, VideoInclude, VideoPrivacy } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
ObjectStorageCommand,
@ -104,6 +104,20 @@ describe('Test video source management', function () {
expect(source.metadata?.streams).to.be.an('array')
})
it('Should include video source file when listing videos in admin', async function () {
const { total, data } = await servers[0].videos.listAllForAdmin({ include: VideoInclude.SOURCE, sort: 'publishedAt' })
expect(total).to.equal(2)
expect(data).to.have.lengthOf(2)
expect(data[0].videoSource).to.exist
expect(data[0].videoSource.inputFilename).to.equal(fixture1)
expect(data[0].videoSource.fileDownloadUrl).to.be.null
expect(data[1].videoSource).to.exist
expect(data[1].videoSource.inputFilename).to.equal(fixture2)
expect(data[1].videoSource.fileDownloadUrl).to.exist
})
it('Should have kept original video file', async function () {
await checkSourceFile({ server: servers[0], fsCount: 1, fixture: fixture2, uuid: uuids[uuids.length - 1] })
})
@ -135,6 +149,33 @@ describe('Test video source management', function () {
expect(source.metadata?.streams).to.be.an('array')
})
it('Should delete video source file', async function () {
await servers[0].videos.deleteSource({ id: uuids[uuids.length - 1] })
const { total, data } = await servers[0].videos.listAllForAdmin({ include: VideoInclude.SOURCE, sort: 'publishedAt' })
expect(total).to.equal(3)
expect(data).to.have.lengthOf(3)
expect(data[0].videoSource).to.exist
expect(data[0].videoSource.inputFilename).to.equal(fixture1)
expect(data[0].videoSource.fileDownloadUrl).to.be.null
expect(data[1].videoSource).to.exist
expect(data[1].videoSource.inputFilename).to.equal(fixture2)
expect(data[1].videoSource.fileDownloadUrl).to.exist
expect(data[2].videoSource).to.exist
expect(data[2].videoSource.fileDownloadUrl).to.not.exist
expect(data[2].videoSource.createdAt).to.exist
expect(data[2].videoSource.fps).to.be.null
expect(data[2].videoSource.height).to.be.null
expect(data[2].videoSource.width).to.be.null
expect(data[2].videoSource.resolution.id).to.be.null
expect(data[2].videoSource.resolution.label).to.be.null
expect(data[2].videoSource.size).to.be.null
expect(data[2].videoSource.metadata).to.be.null
})
it('Should delete all videos and do not have original files anymore', async function () {
this.timeout(60000)

View File

@ -1,5 +1,5 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { VideoState } from '@peertube/peertube-models'
import { HttpStatusCode, UserRight, VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
@ -19,6 +19,7 @@ import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import {
asyncMiddleware,
authenticate,
ensureUserHasRight,
replaceVideoSourceResumableInitValidator,
replaceVideoSourceResumableValidator,
videoSourceGetLatestValidator
@ -35,6 +36,14 @@ videoSourceRouter.get('/:id/source',
getVideoLatestSource
)
videoSourceRouter.delete('/:id/source/file',
openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
asyncMiddleware(videoSourceGetLatestValidator),
asyncMiddleware(deleteVideoLatestSourceFile)
)
setupUploadResumableRoutes({
routePath: '/:id/source/replace-resumable',
router: videoSourceRouter,
@ -52,6 +61,24 @@ export {
// ---------------------------------------------------------------------------
async function deleteVideoLatestSourceFile (req: express.Request, res: express.Response) {
const videoSource = res.locals.videoSource
const video = res.locals.videoAll
await video.removeOriginalFile(videoSource)
videoSource.keptOriginalFilename = null
videoSource.fps = null
videoSource.resolution = null
videoSource.width = null
videoSource.height = null
videoSource.metadata = null
videoSource.size = null
await videoSource.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
function getVideoLatestSource (req: express.Request, res: express.Response) {
return res.json(res.locals.videoSource.toFormattedJSON())
}

View File

@ -472,7 +472,7 @@ const commonVideosFiltersValidator = [
if (req.query.include || req.query.privacyOneOf) {
return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: 'You are not allowed to see all videos.'
message: 'You are not allowed to see all videos or specify a custom include.'
})
}
}

View File

@ -27,6 +27,7 @@ export type VideoFormattingJSONOptions = {
scheduledUpdate?: boolean
blacklistInfo?: boolean
files?: boolean
source?: boolean
blockedOwner?: boolean
}
}
@ -41,6 +42,7 @@ export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfte
scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
files: !!(query.include & VideoInclude.FILES),
source: !!(query.include & VideoInclude.SOURCE),
blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
}
}
@ -310,5 +312,9 @@ function buildAdditionalAttributes (video: MVideoFormattable, options: VideoForm
result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
}
if (add?.source === true) {
result.videoSource = video.VideoSource?.toFormattedJSON() || null
}
return result
}

View File

@ -247,6 +247,18 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
}
}
protected includeVideoSource () {
this.addJoin(
'LEFT OUTER JOIN "videoSource" AS "VideoSource" ON "video"."id" = "VideoSource"."videoId"'
)
this.attributes = {
...this.attributes,
...this.buildAttributesObject('VideoSource', this.tables.getVideoSourceAttributes())
}
}
protected includeTrackers () {
this.addJoin(
'LEFT OUTER JOIN (' +

View File

@ -8,6 +8,7 @@ import { ServerBlocklistModel } from '@server/models/server/server-blocklist.js'
import { ServerModel } from '@server/models/server/server.js'
import { TrackerModel } from '@server/models/server/tracker.js'
import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { ScheduleVideoUpdateModel } from '../../../schedule-video-update.js'
import { TagModel } from '../../../tag.js'
import { ThumbnailModel } from '../../../thumbnail.js'
@ -39,6 +40,7 @@ export class VideoModelBuilder {
private accountBlocklistDone: Set<any>
private serverBlocklistDone: Set<any>
private liveDone: Set<any>
private sourceDone: Set<any>
private redundancyDone: Set<any>
private scheduleVideoUpdateDone: Set<any>
@ -108,6 +110,10 @@ export class VideoModelBuilder {
this.setBlockedOwner(row, videoModel)
this.setBlockedServer(row, videoModel)
}
if (include & VideoInclude.SOURCE) {
this.setSource(row, videoModel)
}
}
}
@ -127,6 +133,7 @@ export class VideoModelBuilder {
this.historyDone = new Set()
this.blacklistDone = new Set()
this.liveDone = new Set()
this.sourceDone = new Set()
this.redundancyDone = new Set()
this.scheduleVideoUpdateDone = new Set()
@ -391,6 +398,16 @@ export class VideoModelBuilder {
this.liveDone.add(id)
}
private setSource (row: SQLRow, videoModel: VideoModel) {
const id = row['VideoSource.id']
if (!id || this.sourceDone.has(id)) return
const attributes = this.grab(row, this.tables.getVideoSourceAttributes(), 'VideoSource')
videoModel.VideoSource = new VideoSourceModel(attributes, this.buildOpts)
this.sourceDone.add(id)
}
private grab (row: SQLRow, attributes: string[], prefix: string) {
const result: { [ id: string ]: string | number } = {}

View File

@ -168,6 +168,21 @@ export class VideoTableAttributes {
]
}
getVideoSourceAttributes () {
return [
'id',
'inputFilename',
'keptOriginalFilename',
'resolution',
'size',
'width',
'height',
'fps',
'metadata',
'createdAt'
]
}
getTrackerAttributes () {
return [ 'id', 'url' ]
}

View File

@ -702,9 +702,11 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
'(SELECT COALESCE(SUM(size), 0) FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")' +
' + ' +
'(SELECT COALESCE(SUM(size), 0) FROM "videoFile" ' +
'INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
'AND "videoStreamingPlaylist"."videoId" = "video"."id"' +
'INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
'AND "videoStreamingPlaylist"."videoId" = "video"."id"' +
')' +
' + ' +
'(SELECT COALESCE(SUM(size), 0) FROM "videoSource" WHERE "videoSource"."videoId" = "video"."id")' +
') END' +
') AS "localVideoFilesSize"'
)

View File

@ -96,6 +96,10 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
this.includeBlockedOwnerAndServer(options.serverAccountIdForBlock, options.user)
}
if (options.include & VideoInclude.SOURCE) {
this.includeVideoSource()
}
const select = this.buildSelect()
this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}`

View File

@ -117,7 +117,9 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
resolution: {
id: this.resolution,
label: getResolutionLabel(this.resolution)
label: this.resolution !== null
? getResolutionLabel(this.resolution)
: null
},
size: this.size,

View File

@ -3558,6 +3558,24 @@ paths:
schema:
$ref: '#/components/schemas/VideoSource'
'/api/v1/videos/{id}/source/file':
delete:
summary: Delete video source file
operationId: deleteVideoSourceFile
tags:
- Video
security:
- OAuth2:
- admin
- moderator
parameters:
- $ref: '#/components/parameters/idOrUUID'
responses:
'204':
description: successful operation
'404':
description: video source not found
'/api/v1/videos/{id}/source/replace-resumable':
post:
summary: Initialize the resumable replacement of a video
@ -6988,6 +7006,8 @@ components:
- 2
- 4
- 8
- 16
- 32
description: >
**PeerTube >= 4.0** Include additional videos in results (can be combined using bitwise or operator)
@ -7000,6 +7020,10 @@ components:
- `4` BLOCKED_OWNER
- `8` FILES
- `16` CAPTIONS
- `32` VIDEO SOURCE
subscriptionsUris:
name: uris
in: query