1
0
Fork 0

Add ability to list and delete original file

In admin
This commit is contained in:
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 | let include = VideoInclude.BLACKLISTED |
VideoInclude.BLOCKED_OWNER | VideoInclude.BLOCKED_OWNER |
VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.NOT_PUBLISHED_STATE |
VideoInclude.FILES VideoInclude.FILES |
VideoInclude.SOURCE
let privacyOneOf = getAllPrivacies() let privacyOneOf = getAllPrivacies()

View File

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

View File

@ -1,32 +1,32 @@
import { SortMeta, SharedModule } from 'primeng/api' import { DatePipe, NgClass, NgFor, NgIf } from '@angular/common'
import { finalize } from 'rxjs/operators'
import { Component, OnInit, ViewChild } from '@angular/core' import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { formatICU, getAbsoluteAPIUrl } from '@app/helpers' 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 { getAllFiles } from '@peertube/peertube-core-utils'
import { UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models' import { UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { VideoAdminService } from './video-admin.service' import { SharedModule, SortMeta } from 'primeng/api'
import { BytesPipe } from '../../../shared/shared-main/angular/bytes.pipe' import { TableModule } from 'primeng/table'
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component' 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 { 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 { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component'
import { import {
VideoActionsDisplayType, VideoActionsDisplayType,
VideoActionsDropdownComponent VideoActionsDropdownComponent
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component' } from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component' import { VideoAdminService } from './video-admin.service'
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'
@Component({ @Component({
selector: 'my-video-list', selector: 'my-video-list',
@ -187,6 +187,10 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
return video.state.id === VideoState.TO_IMPORT return video.state.id === VideoState.TO_IMPORT
} }
hasOriginalFile (video: Video) {
return !!video.videoSource?.fileDownloadUrl
}
hasHLS (video: Video) { hasHLS (video: Video) {
const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
if (!p) return false if (!p) return false
@ -211,13 +215,10 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
} }
getFilesSize (video: Video) { 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)) { return total
files = files.concat(video.streamingPlaylists[0].files)
}
return files.reduce((p, f) => p += f.size, 0)
} }
async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'web-videos') { 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 () { protected reloadDataInternal () {
this.loading = true this.loading = true

View File

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

View File

@ -326,6 +326,11 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .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: { runTranscoding (options: {
videoIds: (number | string)[] videoIds: (number | string)[]
type: 'hls' | 'web-video' type: 'hls' | 'web-video'

View File

@ -4,7 +4,8 @@ export const VideoInclude = {
BLACKLISTED: 1 << 1, BLACKLISTED: 1 << 1,
BLOCKED_OWNER: 1 << 2, BLOCKED_OWNER: 1 << 2,
FILES: 1 << 3, FILES: 1 << 3,
CAPTIONS: 1 << 4 CAPTIONS: 1 << 4,
SOURCE: 1 << 5
} as const } as const
export type VideoIncludeType = typeof VideoInclude[keyof typeof VideoInclude] 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 { VideoConstant } from './video-constant.model.js'
import { VideoPrivacyType } from './video-privacy.enum.js' import { VideoPrivacyType } from './video-privacy.enum.js'
import { VideoScheduleUpdate } from './video-schedule-update.model.js' import { VideoScheduleUpdate } from './video-schedule-update.model.js'
import { VideoSource } from './video-source.model.js'
import { VideoStateType } from './video-state.enum.js' import { VideoStateType } from './video-state.enum.js'
import { VideoStreamingPlaylist } from './video-streaming-playlist.model.js' import { VideoStreamingPlaylist } from './video-streaming-playlist.model.js'
@ -75,6 +76,8 @@ export interface VideoAdditionalAttributes {
files: VideoFile[] files: VideoFile[]
streamingPlaylists: VideoStreamingPlaylist[] streamingPlaylists: VideoStreamingPlaylist[]
videoSource: VideoSource
} }
export interface VideoDetails extends Video { 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 & { async getId (options: OverrideCommandOptions & {
uuid: number | string uuid: number | string
}) { }) {
@ -273,12 +287,12 @@ export class VideosCommand extends AbstractCommand {
const privacyOneOf = getAllPrivacies() const privacyOneOf = getAllPrivacies()
return this.list({ return this.list({
...options,
include, include,
nsfw, nsfw,
privacyOneOf, privacyOneOf,
...options,
token: this.buildCommonRequestToken({ ...options, implicitToken: true }) 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 () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View File

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

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { getAllFiles } from '@peertube/peertube-core-utils' 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 { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { import {
ObjectStorageCommand, ObjectStorageCommand,
@ -104,6 +104,20 @@ describe('Test video source management', function () {
expect(source.metadata?.streams).to.be.an('array') 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 () { it('Should have kept original video file', async function () {
await checkSourceFile({ server: servers[0], fsCount: 1, fixture: fixture2, uuid: uuids[uuids.length - 1] }) 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') 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 () { it('Should delete all videos and do not have original files anymore', async function () {
this.timeout(60000) this.timeout(60000)

View File

@ -1,5 +1,5 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils' 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 { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js' import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js' import { Hooks } from '@server/lib/plugins/hooks.js'
@ -19,6 +19,7 @@ import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { import {
asyncMiddleware, asyncMiddleware,
authenticate, authenticate,
ensureUserHasRight,
replaceVideoSourceResumableInitValidator, replaceVideoSourceResumableInitValidator,
replaceVideoSourceResumableValidator, replaceVideoSourceResumableValidator,
videoSourceGetLatestValidator videoSourceGetLatestValidator
@ -35,6 +36,14 @@ videoSourceRouter.get('/:id/source',
getVideoLatestSource getVideoLatestSource
) )
videoSourceRouter.delete('/:id/source/file',
openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
asyncMiddleware(videoSourceGetLatestValidator),
asyncMiddleware(deleteVideoLatestSourceFile)
)
setupUploadResumableRoutes({ setupUploadResumableRoutes({
routePath: '/:id/source/replace-resumable', routePath: '/:id/source/replace-resumable',
router: videoSourceRouter, 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) { function getVideoLatestSource (req: express.Request, res: express.Response) {
return res.json(res.locals.videoSource.toFormattedJSON()) return res.json(res.locals.videoSource.toFormattedJSON())
} }

View File

@ -472,7 +472,7 @@ const commonVideosFiltersValidator = [
if (req.query.include || req.query.privacyOneOf) { if (req.query.include || req.query.privacyOneOf) {
return res.fail({ return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401, 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 scheduledUpdate?: boolean
blacklistInfo?: boolean blacklistInfo?: boolean
files?: boolean files?: boolean
source?: boolean
blockedOwner?: boolean blockedOwner?: boolean
} }
} }
@ -41,6 +42,7 @@ export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfte
scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED), blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
files: !!(query.include & VideoInclude.FILES), files: !!(query.include & VideoInclude.FILES),
source: !!(query.include & VideoInclude.SOURCE),
blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER) blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
} }
} }
@ -310,5 +312,9 @@ function buildAdditionalAttributes (video: MVideoFormattable, options: VideoForm
result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles) result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
} }
if (add?.source === true) {
result.videoSource = video.VideoSource?.toFormattedJSON() || null
}
return result 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 () { protected includeTrackers () {
this.addJoin( this.addJoin(
'LEFT OUTER JOIN (' + '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 { ServerModel } from '@server/models/server/server.js'
import { TrackerModel } from '@server/models/server/tracker.js' import { TrackerModel } from '@server/models/server/tracker.js'
import { UserVideoHistoryModel } from '@server/models/user/user-video-history.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 { ScheduleVideoUpdateModel } from '../../../schedule-video-update.js'
import { TagModel } from '../../../tag.js' import { TagModel } from '../../../tag.js'
import { ThumbnailModel } from '../../../thumbnail.js' import { ThumbnailModel } from '../../../thumbnail.js'
@ -39,6 +40,7 @@ export class VideoModelBuilder {
private accountBlocklistDone: Set<any> private accountBlocklistDone: Set<any>
private serverBlocklistDone: Set<any> private serverBlocklistDone: Set<any>
private liveDone: Set<any> private liveDone: Set<any>
private sourceDone: Set<any>
private redundancyDone: Set<any> private redundancyDone: Set<any>
private scheduleVideoUpdateDone: Set<any> private scheduleVideoUpdateDone: Set<any>
@ -108,6 +110,10 @@ export class VideoModelBuilder {
this.setBlockedOwner(row, videoModel) this.setBlockedOwner(row, videoModel)
this.setBlockedServer(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.historyDone = new Set()
this.blacklistDone = new Set() this.blacklistDone = new Set()
this.liveDone = new Set() this.liveDone = new Set()
this.sourceDone = new Set()
this.redundancyDone = new Set() this.redundancyDone = new Set()
this.scheduleVideoUpdateDone = new Set() this.scheduleVideoUpdateDone = new Set()
@ -391,6 +398,16 @@ export class VideoModelBuilder {
this.liveDone.add(id) 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) { private grab (row: SQLRow, attributes: string[], prefix: string) {
const result: { [ id: string ]: string | number } = {} 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 () { getTrackerAttributes () {
return [ 'id', 'url' ] 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" WHERE "videoFile"."videoId" = "video"."id")' +
' + ' + ' + ' +
'(SELECT COALESCE(SUM(size), 0) FROM "videoFile" ' + '(SELECT COALESCE(SUM(size), 0) FROM "videoFile" ' +
'INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' + 'INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
'AND "videoStreamingPlaylist"."videoId" = "video"."id"' + 'AND "videoStreamingPlaylist"."videoId" = "video"."id"' +
')' + ')' +
' + ' +
'(SELECT COALESCE(SUM(size), 0) FROM "videoSource" WHERE "videoSource"."videoId" = "video"."id")' +
') END' + ') END' +
') AS "localVideoFilesSize"' ') AS "localVideoFilesSize"'
) )

View File

@ -96,6 +96,10 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
this.includeBlockedOwnerAndServer(options.serverAccountIdForBlock, options.user) this.includeBlockedOwnerAndServer(options.serverAccountIdForBlock, options.user)
} }
if (options.include & VideoInclude.SOURCE) {
this.includeVideoSource()
}
const select = this.buildSelect() const select = this.buildSelect()
this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}` 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: { resolution: {
id: this.resolution, id: this.resolution,
label: getResolutionLabel(this.resolution) label: this.resolution !== null
? getResolutionLabel(this.resolution)
: null
}, },
size: this.size, size: this.size,

View File

@ -3558,6 +3558,24 @@ paths:
schema: schema:
$ref: '#/components/schemas/VideoSource' $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': '/api/v1/videos/{id}/source/replace-resumable':
post: post:
summary: Initialize the resumable replacement of a video summary: Initialize the resumable replacement of a video
@ -6988,6 +7006,8 @@ components:
- 2 - 2
- 4 - 4
- 8 - 8
- 16
- 32
description: > description: >
**PeerTube >= 4.0** Include additional videos in results (can be combined using bitwise or operator) **PeerTube >= 4.0** Include additional videos in results (can be combined using bitwise or operator)
@ -7000,6 +7020,10 @@ components:
- `4` BLOCKED_OWNER - `4` BLOCKED_OWNER
- `8` FILES - `8` FILES
- `16` CAPTIONS
- `32` VIDEO SOURCE
subscriptionsUris: subscriptionsUris:
name: uris name: uris
in: query in: query