store uploaded video filename (#4885)

* store uploaded video filename

closes #4731

* dont crash if videos channel exist

* migration: use raw query

* video source: fixes after code review

* cleanup

* bump migration

* updates after code review

* refactor: use checkUserCanManageVideo

* videoSource: add openapi doc

* test(check-params/video-source): fix timeout

* Styling

* Correctly set original filename as source

Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
kontrollanten 2022-06-21 15:31:25 +02:00 committed by GitHub
parent dec4952155
commit 2e401e8575
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 339 additions and 11 deletions

View File

@ -340,6 +340,21 @@
</div>
<div class="col-md-12 col-xl-4">
<div *ngIf="videoSource" class="form-group">
<label i18n for="filename">Filename</label>
<my-help>
<ng-template ptTemplate="preHtml">
<ng-container i18n>
Name of the uploaded file
</ng-container>
</ng-template>
</my-help>
<input type="text" [disabled]="true" id="filename" class="form-control" [value]="videoSource.filename" />
</div>
<div class="form-group originally-published-at">
<label i18n for="originallyPublishedAt">Original publication date</label>
<my-help>

View File

@ -37,6 +37,7 @@ import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalComponent } from './video-caption-edit-modal/video-caption-edit-modal.component'
import { VideoEditType } from './video-edit.type'
import { VideoSource } from '@shared/models/videos/video-source'
type VideoLanguages = VideoConstant<string> & { group?: string }
type PluginField = {
@ -61,6 +62,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
@Input() forbidScheduledPublication = true
@Input() videoCaptions: VideoCaptionWithPathEdit[] = []
@Input() videoSource: VideoSource
@Input() waitTranscodingEnabled = true
@Input() type: VideoEditType

View File

@ -12,6 +12,7 @@
[videoCaptions]="videoCaptions" [waitTranscodingEnabled]="isWaitTranscodingEnabled()"
type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
[liveVideo]="liveVideo" [videoToUpdate]="videoDetails"
[videoSource]="videoSource"
(formBuilt)="onFormBuilt()"
></my-video-edit>

View File

@ -10,6 +10,7 @@ import { LiveVideoService } from '@app/shared/shared-video-live'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { LiveVideo, LiveVideoUpdate, VideoPrivacy } from '@shared/models'
import { hydrateFormFromVideo } from './shared/video-edit-utils'
import { VideoSource } from '@shared/models/videos/video-source'
@Component({
selector: 'my-videos-update',
@ -19,6 +20,7 @@ import { hydrateFormFromVideo } from './shared/video-edit-utils'
export class VideoUpdateComponent extends FormReactive implements OnInit {
video: VideoEdit
videoDetails: VideoDetails
videoSource: VideoSource
userVideoChannels: SelectChannelItem[] = []
videoCaptions: VideoCaptionEdit[] = []
liveVideo: LiveVideo
@ -46,13 +48,14 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.buildForm({})
const { videoData } = this.route.snapshot.data
const { video, videoChannels, videoCaptions, liveVideo } = videoData
const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData
this.video = new VideoEdit(video)
this.videoDetails = video
this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions
this.videoSource = videoSource
this.liveVideo = liveVideo
this.forbidScheduledPublication = this.video.privacy !== VideoPrivacy.PRIVATE

View File

@ -23,7 +23,8 @@ export class VideoUpdateResolver implements Resolve<any> {
return this.videoService.getVideo({ videoId: uuid })
.pipe(
switchMap(video => forkJoin(this.buildVideoObservables(video))),
map(([ video, videoChannels, videoCaptions, liveVideo ]) => ({ video, videoChannels, videoCaptions, liveVideo }))
map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) =>
({ video, videoChannels, videoCaptions, videoSource, liveVideo }))
)
}
@ -33,6 +34,8 @@ export class VideoUpdateResolver implements Resolve<any> {
.loadCompleteDescription(video.descriptionPath)
.pipe(map(description => Object.assign(video, { description }))),
this.videoService.getSource(video.id),
listUserChannelsForSelect(this.authService),
this.videoCaptionService

View File

@ -1,5 +1,5 @@
import { SortMeta } from 'primeng/api'
import { from, Observable } from 'rxjs'
import { from, Observable, of } from 'rxjs'
import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators'
import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
import { Injectable } from '@angular/core'
@ -24,6 +24,7 @@ import {
VideoTranscodingCreate,
VideoUpdate
} from '@shared/models'
import { VideoSource } from '@shared/models/videos/video-source'
import { environment } from '../../../../environments/environment'
import { Account } from '../account/account.model'
import { AccountService } from '../account/account.service'
@ -323,6 +324,20 @@ export class VideoService {
)
}
getSource (videoId: number) {
return this.authHttp
.get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
.pipe(
catchError(err => {
if (err.status === 404) {
return of(undefined)
}
this.restExtractor.handleError(err)
})
)
}
setVideoLike (id: number) {
return this.setVideoRate(id, 'like')
}

View File

@ -26,6 +26,7 @@ import {
setDefaultVideosSort,
videosCustomGetValidator,
videosGetValidator,
videoSourceGetValidator,
videosRemoveValidator,
videosSortValidator
} from '../../../middlewares'
@ -96,6 +97,14 @@ videosRouter.get('/:id/description',
asyncMiddleware(videosGetValidator),
asyncMiddleware(getVideoDescription)
)
videosRouter.get('/:id/source',
openapiOperationDoc({ operationId: 'getVideoSource' }),
authenticate,
asyncMiddleware(videoSourceGetValidator),
getVideoSource
)
videosRouter.get('/:id',
openapiOperationDoc({ operationId: 'getVideo' }),
optionalAuthenticate,
@ -155,6 +164,10 @@ async function getVideoDescription (req: express.Request, res: express.Response)
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) {
const serverActor = await getServerActor()

View File

@ -44,6 +44,7 @@ import {
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { VideoModel } from '../../../models/video/video'
import { VideoFileModel } from '../../../models/video/video-file'
import { VideoSourceModel } from '@server/models/video/video-source'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@ -151,6 +152,7 @@ async function addVideo (options: {
video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
const videoFile = await buildNewFile(videoPhysicalFile)
const originalFilename = videoPhysicalFile.originalname
// Move physical file
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
@ -181,6 +183,11 @@ async function addVideo (options: {
video.VideoFiles = [ videoFile ]
await VideoSourceModel.create({
filename: originalFilename,
videoId: video.id
}, { transaction: t })
await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
// Schedule an update in the future?

View File

@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 710
const LAST_MIGRATION_VERSION = 715
// ---------------------------------------------------------------------------

View File

@ -49,6 +49,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
import { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/view/video-view'
import { CONFIG } from './config'
import { VideoSourceModel } from '@server/models/video/video-source'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -126,6 +127,7 @@ async function initDatabaseModels (silent: boolean) {
VideoChannelModel,
VideoShareModel,
VideoFileModel,
VideoSourceModel,
VideoCaptionModel,
VideoBlacklistModel,
VideoTagModel,

View File

@ -0,0 +1,34 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
{
const query = `
CREATE TABLE IF NOT EXISTS "videoSource" (
"id" SERIAL ,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"filename" VARCHAR(255) DEFAULT NULL,
"videoId" INTEGER
REFERENCES "video" ("id")
ON DELETE CASCADE
ON UPDATE CASCADE,
PRIMARY KEY ("id")
);
`
await utils.sequelize.query(query)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -9,6 +9,7 @@ export * from './video-ownership-changes'
export * from './video-view'
export * from './video-rates'
export * from './video-shares'
export * from './video-source'
export * from './video-stats'
export * from './video-studio'
export * from './video-transcoding'

View File

@ -0,0 +1,37 @@
import express from 'express'
import { getVideoWithAttributes } from '@server/helpers/video'
import { VideoSourceModel } from '@server/models/video/video-source'
import { MVideoFullLight } from '@server/types/models'
import { HttpStatusCode, UserRight } from '@shared/models'
import { logger } from '../../../helpers/logger'
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
const videoSourceGetValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoSourceGet parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, 'for-api')) return
const video = getVideoWithAttributes(res) as MVideoFullLight
res.locals.videoSource = await VideoSourceModel.loadByVideoId(video.id)
if (!res.locals.videoSource) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video source not found'
})
}
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return
return next()
}
]
export {
videoSourceGetValidator
}

View File

@ -152,7 +152,7 @@ const videosAddResumableValidator = [
if (!await isVideoAccepted(req, res, file)) return cleanup()
res.locals.videoFileResumable = file
res.locals.videoFileResumable = { ...file, originalname: file.filename }
return next()
}

View File

@ -0,0 +1,55 @@
import { Op } from 'sequelize'
import {
AllowNull,
BelongsTo,
Column,
CreatedAt,
ForeignKey,
Model,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { AttributesOnly } from '@shared/typescript-utils'
import { VideoModel } from './video'
@Table({
tableName: 'videoSource',
indexes: [
{
fields: [ 'videoId' ],
where: {
videoId: {
[Op.ne]: null
}
}
}
]
})
export class VideoSourceModel extends Model<Partial<AttributesOnly<VideoSourceModel>>> {
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
@AllowNull(false)
@Column
filename: string
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel)
Video: VideoModel
static loadByVideoId (videoId) {
return VideoSourceModel.findOne({ where: { videoId } })
}
toFormattedJSON () {
return {
filename: this.filename
}
}
}

View File

@ -136,6 +136,7 @@ import { VideoPlaylistElementModel } from './video-playlist-element'
import { VideoShareModel } from './video-share'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
import { VideoTagModel } from './video-tag'
import { VideoSourceModel } from './video-source'
export enum ScopeNames {
FOR_API = 'FOR_API',
@ -597,6 +598,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
})
VideoPlaylistElements: VideoPlaylistElementModel[]
@HasOne(() => VideoSourceModel, {
foreignKey: {
name: 'videoId',
allowNull: true
},
onDelete: 'CASCADE'
})
VideoSource: VideoSourceModel
@HasMany(() => VideoAbuseModel, {
foreignKey: {
name: 'videoId',

View File

@ -3,14 +3,14 @@ import './accounts'
import './blocklist'
import './bulk'
import './config'
import './custom-pages'
import './contact-form'
import './custom-pages'
import './debug'
import './follows'
import './jobs'
import './live'
import './logs'
import './my-user'
import './live'
import './plugins'
import './redundancy'
import './search'
@ -25,12 +25,13 @@ import './video-blacklist'
import './video-captions'
import './video-channels'
import './video-comments'
import './video-studio'
import './video-files'
import './video-imports'
import './video-playlists'
import './videos'
import './video-source'
import './video-studio'
import './videos-common-filters'
import './video-files'
import './videos-history'
import './videos-overviews'
import './videos'
import './views'

View File

@ -0,0 +1,44 @@
import { HttpStatusCode } from '@shared/models'
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
describe('Test video sources API validator', function () {
let server: PeerTubeServer = null
let uuid: string
let userToken: string
before(async function () {
this.timeout(30000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
const created = await server.videos.quickUpload({ name: 'video' })
uuid = created.uuid
userToken = await server.users.generateUserAndToken('user')
})
it('Should fail without a valid uuid', async function () {
await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should receive 404 when passing a non existing video id', async function () {
await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should not get the source as unauthenticated', async function () {
await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null })
})
it('Should not get the source with another user', async function () {
await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: userToken })
})
it('Should succeed with the correct parameters get the source as another user', async function () {
await server.videos.getSource({ id: uuid })
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -16,3 +16,4 @@ import './video-schedule-update'
import './videos-common-filters'
import './videos-history'
import './videos-overview'
import './video-source'

View File

@ -0,0 +1,39 @@
import 'mocha'
import * as chai from 'chai'
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
const expect = chai.expect
describe('Test video source', () => {
let server: PeerTubeServer = null
const fixture = 'video_short.webm'
before(async function () {
this.timeout(30000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
})
it('Should get the source filename with legacy upload', async function () {
this.timeout(30000)
const { uuid } = await server.videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' })
const source = await server.videos.getSource({ id: uuid })
expect(source.filename).to.equal(fixture)
})
it('Should get the source filename with resumable upload', async function () {
this.timeout(30000)
const { uuid } = await server.videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' })
const source = await server.videos.getSource({ id: uuid })
expect(source.filename).to.equal(fixture)
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -42,6 +42,7 @@ import {
MVideoThumbnail
} from './models'
import { Writable } from 'stream'
import { MVideoSource } from './models/video/video-source'
declare module 'express' {
export interface Request {
@ -68,7 +69,7 @@ declare module 'express' {
} | UploadFileForCheck[]
// Upload file with a duration added by our middleware
export type VideoUploadFile = Pick<Express.Multer.File, 'path' | 'filename' | 'size'> & {
export type VideoUploadFile = Pick<Express.Multer.File, 'path' | 'filename' | 'size', 'originalname'> & {
duration: number
}
@ -85,6 +86,7 @@ declare module 'express' {
duration: number
path: string
filename: string
originalname: string
}
// Extends Response with added functions and potential variables passed by middlewares
@ -123,6 +125,8 @@ declare module 'express' {
videoShare?: MVideoShareActor
videoSource?: MVideoSource
videoFile?: MVideoFile
videoFileResumable?: EnhancedUploadXFile

View File

@ -0,0 +1,3 @@
import { VideoSourceModel } from '@server/models/video/video-source'
export type MVideoSource = Omit<VideoSourceModel, 'Video'>

View File

@ -0,0 +1,3 @@
export interface VideoSource {
filename: string
}

View File

@ -23,6 +23,7 @@ import {
import { unwrapBody } from '../requests'
import { waitJobs } from '../server'
import { AbstractCommand, OverrideCommandOptions } from '../shared'
import { VideoSource } from '@shared/models/videos/video-source'
export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
fixture?: string
@ -150,6 +151,20 @@ export class VideosCommand extends AbstractCommand {
})
}
getSource (options: OverrideCommandOptions & {
id: number | string
}) {
const path = '/api/v1/videos/' + options.id + '/source'
return this.getRequestBody<VideoSource>({
...options,
path,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
async getId (options: OverrideCommandOptions & {
uuid: number | string
}) {

View File

@ -1903,6 +1903,22 @@ paths:
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)**
'/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'
'/videos/{id}/views':
post:
summary: Notify user is watching a video
@ -6141,6 +6157,10 @@ components:
$ref: '#/components/schemas/VideoConstantString-Language'
captionPath:
type: string
VideoSource:
properties:
filename:
type: string
ActorImage:
properties:
path: