1
0
Fork 0

Add ability to put captions in object storage

Deprecate:
 * `path` and `url` of `ActorImage` (used to represent account/channel
   avatars/banners) in favour of `fileUrl`
 * `path` of `AvatarInfo` (used in notifications) in favour of `fileUrl`
 * `captionPath` of `VideoCaption` in favour of `fileUrl`
 * `storyboardPath` of `Storyboard` in favour of `fileUrl`
This commit is contained in:
Chocobozzz 2025-02-07 09:04:20 +01:00
parent e6725e6d3a
commit 260447942a
No known key found for this signature in database
GPG key ID: 583A612D890159BE
69 changed files with 1322 additions and 518 deletions

View file

@ -135,10 +135,10 @@ export class VideoCaptionEditModalContentComponent extends FormReactive implemen
return
}
const { captionPath } = this.videoCaption
if (!captionPath) return
const { fileUrl } = this.videoCaption
if (!fileUrl) return
this.videoCaptionService.getCaptionContent({ captionPath })
this.videoCaptionService.getCaptionContent({ fileUrl })
.subscribe(content => {
this.loadSegments(content)
})

View file

@ -209,7 +209,7 @@
} @else {
<a
i18n-title title="See the subtitle file" class="caption-entry-label" target="_blank" rel="noopener noreferrer"
[href]="videoCaption.captionPath"
[href]="videoCaption.fileUrl"
>{{ getCaptionLabel(videoCaption) }}</a>
<div i18n class="caption-entry-state">Already uploaded on {{ videoCaption.updatedAt | ptDate }} &#10004;</div>

View file

@ -155,7 +155,7 @@ export class VideoTranscriptionComponent implements OnInit, OnChanges {
}
private parseCurrentCaption () {
this.captionService.getCaptionContent({ captionPath: this.currentCaption.captionPath })
this.captionService.getCaptionContent({ fileUrl: this.currentCaption.fileUrl })
.subscribe({
next: content => {
try {

View file

@ -787,12 +787,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
label: c.language.label,
language: c.language.id,
automaticallyGenerated: c.automaticallyGenerated,
src: environment.apiUrl + c.captionPath
src: c.fileUrl
}))
const storyboard = storyboards.length !== 0
? {
url: environment.apiUrl + storyboards[0].storyboardPath,
url: storyboards[0].fileUrl,
height: storyboards[0].spriteHeight,
width: storyboards[0].spriteWidth,
interval: storyboards[0].spriteDuration

View file

@ -17,7 +17,7 @@ export abstract class Actor implements ServerActor {
isLocal: boolean
static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size?: number) {
static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, fileUrl?: string, url?: string, path: string }[] }, size?: number) {
const avatarsAscWidth = actor.avatars.sort((a, b) => a.width - b.width)
const avatar = size && avatarsAscWidth.length > 1
@ -25,6 +25,7 @@ export abstract class Actor implements ServerActor {
: avatarsAscWidth[avatarsAscWidth.length - 1] // Bigger one
if (!avatar) return ''
if (avatar.fileUrl) return avatar.fileUrl
if (avatar.url) return avatar.url
const absoluteAPIUrl = getAbsoluteAPIUrl()

View file

@ -25,7 +25,12 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
viewsPerDay?: ViewsPerDate[]
totalViews?: number
static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
static GET_ACTOR_AVATAR_URL (
actor: {
avatars: { width: number, fileUrl?: string, url?: string, path: string }[]
},
size: number
) {
return Actor.GET_ACTOR_AVATAR_URL(actor, size)
}

View file

@ -293,11 +293,17 @@ export class UserNotification implements UserNotificationServer {
return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
}
private setAccountAvatarUrl (actor: { avatarUrl?: string, avatars: { width: number, url?: string, path: string }[] }) {
private setAccountAvatarUrl (actor: {
avatarUrl?: string
avatars: { width: number, fileUrl?: string, url?: string, path: string }[]
}) {
actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor, 48) || Account.GET_DEFAULT_AVATAR_URL(48)
}
private setVideoChannelAvatarUrl (actor: { avatarUrl?: string, avatars: { width: number, url?: string, path: string }[] }) {
private setVideoChannelAvatarUrl (actor: {
avatarUrl?: string
avatars: { width: number, fileUrl?: string, url?: string, path: string }[]
}) {
actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor, 48) || VideoChannel.GET_DEFAULT_AVATAR_URL(48)
}
}

View file

@ -11,4 +11,4 @@ export interface VideoCaptionEdit {
updatedAt?: string
}
export type VideoCaptionWithPathEdit = VideoCaptionEdit & { captionPath?: string }
export type VideoCaptionWithPathEdit = VideoCaptionEdit & { fileUrl?: string }

View file

@ -6,7 +6,6 @@ import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils'
import { PeerTubeProblemDocument, ResultList, ServerErrorCode, Video, VideoCaption, VideoCaptionGenerate } from '@peertube/peertube-models'
import { Observable, from, of, throwError } from 'rxjs'
import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators'
import { environment } from '../../../../environments/environment'
import { VideoPasswordService } from '../video/video-password.service'
import { VideoService } from '../video/video.service'
import { VideoCaptionEdit } from './video-caption-edit.model'
@ -72,8 +71,8 @@ export class VideoCaptionService {
return obs
}
getCaptionContent ({ captionPath }: Pick<VideoCaption, 'captionPath'>) {
return this.authHttp.get(environment.originServerUrl + captionPath, { responseType: 'text' })
getCaptionContent ({ fileUrl }: Pick<VideoCaption, 'fileUrl'>) {
return this.authHttp.get(fileUrl, { responseType: 'text' })
}
// ---------------------------------------------------------------------------

View file

@ -60,6 +60,6 @@ export class SubtitleFilesDownloadComponent implements OnInit {
const caption = this.getCaption()
if (!caption) return ''
return window.location.origin + caption.captionPath
return caption.fileUrl
}
}

View file

@ -335,7 +335,7 @@ export class PlayerOptionsBuilder {
if (!storyboards || storyboards.length === 0) return undefined
return {
url: getBackendUrl() + storyboards[0].storyboardPath,
url: storyboards[0].fileUrl,
height: storyboards[0].spriteHeight,
width: storyboards[0].spriteWidth,
interval: storyboards[0].spriteDuration
@ -428,7 +428,7 @@ export class PlayerOptionsBuilder {
label: peertubeTranslate(c.language.label, translations),
language: c.language.id,
automaticallyGenerated: c.automaticallyGenerated,
src: getBackendUrl() + c.captionPath
src: c.fileUrl
}))
}

View file

@ -260,6 +260,12 @@ object_storage:
prefix: ''
base_url: ''
# Video captions
captions:
bucket_name: 'captions'
prefix: ''
base_url: ''
log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error'

View file

@ -258,6 +258,12 @@ object_storage:
prefix: ''
base_url: ''
# Video captions
captions:
bucket_name: 'captions'
prefix: ''
base_url: ''
log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error'

View file

@ -1,9 +1,13 @@
export interface ActorImage {
width: number
path: string
// TODO: remove, deprecated in 7.1
path: string
// TODO: remove, deprecated in 7.1
url?: string
fileUrl: string
createdAt: Date | string
updatedAt: Date | string
}

View file

@ -204,12 +204,30 @@ export interface DeleteResumableUploadMetaFilePayload {
filepath: string
}
export interface MoveStoragePayload {
// ---------------------------------------------------------------------------
export type MoveStoragePayload = MoveVideoStoragePayload | MoveCaptionPayload
export interface MoveVideoStoragePayload {
videoUUID: string
isNewVideo: boolean
previousVideoState: VideoStateType
}
export interface MoveCaptionPayload {
captionId: number
}
export function isMoveVideoStoragePayload (payload: any): payload is MoveVideoStoragePayload {
return 'videoUUID' in payload
}
export function isMoveCaptionPayload (payload: any): payload is MoveCaptionPayload {
return 'captionId' in payload
}
// ---------------------------------------------------------------------------
export type VideoStudioTaskCutPayload = VideoStudioTaskCut
export type VideoStudioTaskIntroPayload = {

View file

@ -58,7 +58,11 @@ export interface VideoInfo {
export interface AvatarInfo {
width: number
// TODO: remove, deprecated in 7.1
path: string
fileUrl: string
}
export interface ActorInfo {

View file

@ -2,7 +2,12 @@ import { VideoConstant } from '../video-constant.model.js'
export interface VideoCaption {
language: VideoConstant<string>
// TODO: remove, deprecated in 7.1
captionPath: string
fileUrl: string
automaticallyGenerated: boolean
updatedAt: string
}

View file

@ -1,6 +1,9 @@
export interface Storyboard {
// TODO: remove, deprecated in 7.1
storyboardPath: string
fileUrl: string
totalHeight: number
totalWidth: number

View file

@ -4,24 +4,26 @@ import { AbstractCommand } from '../shared/index.js'
export class CLICommand extends AbstractCommand {
static exec (command: string) {
return new Promise<string>((res, rej) => {
exec(command, (err, stdout, _stderr) => {
return new Promise<{ stdout: string, stderr: string }>((res, rej) => {
exec(command, (err, stdout, stderr) => {
if (err) return rej(err)
return res(stdout)
return res({ stdout, stderr })
})
})
}
getEnv () {
return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber}`
static getNodeConfigEnv (configOverride?: any) {
return configOverride
? `NODE_CONFIG='${JSON.stringify(configOverride)}'`
: ''
}
getEnv (configOverride?: any) {
return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber} ${CLICommand.getNodeConfigEnv(configOverride)}`
}
async execWithEnv (command: string, configOverride?: any) {
const prefix = configOverride
? `NODE_CONFIG='${JSON.stringify(configOverride)}'`
: ''
return CLICommand.exec(`${prefix} ${this.getEnv()} ${command}`)
return CLICommand.exec(`${this.getEnv(configOverride)} ${command}`)
}
}

View file

@ -31,8 +31,9 @@ export class ObjectStorageCommand {
getDefaultMockConfig (options: {
storeLiveStreams?: boolean // default true
proxifyPrivateFiles?: boolean // default true
} = {}) {
const { storeLiveStreams = true } = options
const { storeLiveStreams = true, proxifyPrivateFiles = true } = options
return {
object_storage: {
@ -58,6 +59,14 @@ export class ObjectStorageCommand {
original_video_files: {
bucket_name: this.getMockOriginalFileBucketName()
},
captions: {
bucket_name: this.getMockCaptionsBucketName()
},
proxy: {
proxify_private_files: proxifyPrivateFiles
}
}
}
@ -79,9 +88,16 @@ export class ObjectStorageCommand {
return `http://${this.getMockOriginalFileBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/`
}
getMockCaptionFileBaseUrl () {
return `http://${this.getMockCaptionsBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/`
}
async prepareDefaultMockBuckets () {
await this.createMockBucket(this.getMockStreamingPlaylistsBucketName())
await this.createMockBucket(this.getMockWebVideosBucketName())
await this.createMockBucket(this.getMockOriginalFileBucketName())
await this.createMockBucket(this.getMockUserExportBucketName())
await this.createMockBucket(this.getMockCaptionsBucketName())
}
async createMockBucket (name: string) {
@ -124,6 +140,10 @@ export class ObjectStorageCommand {
return this.getMockBucketName(name)
}
getMockCaptionsBucketName (name = 'captions') {
return this.getMockBucketName(name)
}
getMockBucketName (name: string) {
return `${this.seed}-${name}`
}

View file

@ -575,7 +575,7 @@ describe('Test follows', function () {
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/.+-ar.vtt$'))
await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
await testCaptionFile(caption1.fileUrl, 'Subtitle good 2.')
})
it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {

View file

@ -141,7 +141,7 @@ describe('Test multiple servers', function () {
await makeGetRequest({
url: server.url,
path: image.path,
path: image.fileUrl,
expectedStatus: HttpStatusCode.OK_200
})
}

View file

@ -1,17 +1,23 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { wait } from '@peertube/peertube-core-utils'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
makeRawRequest,
ObjectStorageCommand,
PeerTubeServer,
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { testCaptionFile } from '@tests/shared/captions.js'
import { expectStartWith } from '@tests/shared/checks.js'
import { checkDirectoryIsEmpty } from '@tests/shared/directories.js'
import { checkVideoFilesWereRemoved } from '@tests/shared/videos.js'
import { expect } from 'chai'
import { HttpStatusCode } from '../../../../models/src/http/http-status-codes.js'
describe('Test video captions', function () {
const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
@ -35,154 +41,315 @@ describe('Test video captions', function () {
await waitJobs(servers)
})
it('Should list the captions and return an empty list', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(0)
expect(body.data).to.have.lengthOf(0)
}
})
describe('Common on filesystem', function () {
it('Should create two new captions', async function () {
this.timeout(30000)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good1.vtt'
it('Should list the captions and return an empty list', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(0)
expect(body.data).to.have.lengthOf(0)
}
})
await servers[0].captions.add({
language: 'zh',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt',
mimeType: 'application/octet-stream'
it('Should create two new captions', async function () {
this.timeout(30000)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good1.vtt'
})
await servers[0].captions.add({
language: 'zh',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt',
mimeType: 'application/octet-stream'
})
await waitJobs(servers)
})
await waitJobs(servers)
})
it('Should list these uploaded captions', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
it('Should list these uploaded captions', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
expect(caption1.fileUrl).to.match(new RegExp(`^${server.url}/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
expect(caption1.automaticallyGenerated).to.be.false
await testCaptionFile(caption1.fileUrl, 'Subtitle good 1.')
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$'))
expect(caption1.automaticallyGenerated).to.be.false
await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
const caption2 = body.data[1]
expect(caption2.language.id).to.equal('zh')
expect(caption2.language.label).to.equal('Chinese')
expect(caption2.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$'))
expect(caption1.automaticallyGenerated).to.be.false
await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
}
})
it('Should replace an existing caption', async function () {
this.timeout(30000)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt'
const caption2 = body.data[1]
expect(caption2.language.id).to.equal('zh')
expect(caption2.language.label).to.equal('Chinese')
expect(caption2.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/${uuidRegex}-zh.vtt$`))
expect(caption2.fileUrl).to.match(new RegExp(`^${server.url}/lazy-static/video-captions/${uuidRegex}-zh.vtt$`))
expect(caption1.automaticallyGenerated).to.be.false
await testCaptionFile(caption2.fileUrl, 'Subtitle good 2.')
}
})
await waitJobs(servers)
})
it('Should replace an existing caption', async function () {
this.timeout(30000)
it('Should have this caption updated', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt'
})
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$'))
await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
}
})
it('Should replace an existing caption with a srt file and convert it', async function () {
this.timeout(30000)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good.srt'
await waitJobs(servers)
})
await waitJobs(servers)
it('Should have this caption updated', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
// Cache invalidation
await wait(3000)
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
expect(caption1.fileUrl).to.match(new RegExp(`^${server.url}/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
await testCaptionFile(caption1.fileUrl, 'Subtitle good 2.')
}
})
it('Should replace an existing caption with a srt file and convert it', async function () {
this.timeout(30000)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good.srt'
})
await waitJobs(servers)
// Cache invalidation
await wait(3000)
})
it('Should have this caption updated and converted', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.fileUrl).to.match(new RegExp(`${server.url}/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
const expected = 'WEBVTT FILE\r\n' +
'\r\n' +
'1\r\n' +
'00:00:01.600 --> 00:00:04.200\r\n' +
'English (US)\r\n' +
'\r\n' +
'2\r\n' +
'00:00:05.900 --> 00:00:07.999\r\n' +
'This is a subtitle in American English\r\n' +
'\r\n' +
'3\r\n' +
'00:00:10.000 --> 00:00:14.000\r\n' +
'Adding subtitles is very easy to do\r\n'
await testCaptionFile(caption1.fileUrl, expected)
}
})
it('Should remove one caption', async function () {
this.timeout(30000)
await servers[0].captions.delete({ videoId: videoUUID, language: 'ar' })
await waitJobs(servers)
})
it('Should only list the caption that was not deleted', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(1)
expect(body.data).to.have.lengthOf(1)
const caption = body.data[0]
expect(caption.language.id).to.equal('zh')
expect(caption.language.label).to.equal('Chinese')
expect(caption.fileUrl).to.match(new RegExp(`^${server.url}/lazy-static/video-captions/${uuidRegex}-zh.vtt$`))
await testCaptionFile(caption.fileUrl, 'Subtitle good 2.')
}
})
it('Should remove the video, and thus all video captions', async function () {
const video = await servers[0].videos.get({ id: videoUUID })
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
await servers[0].videos.remove({ id: videoUUID })
await checkVideoFilesWereRemoved({ server: servers[0], video, captions })
})
})
it('Should have this caption updated and converted', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
describe('On object storage', function () {
let videoUUID: string
let oldFileUrlsAr: string[] = []
const oldFileUrlsZh: string[] = []
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
expect(caption1.language.label).to.equal('Arabic')
expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$'))
if (areMockObjectStorageTestsDisabled()) return
const expected = 'WEBVTT FILE\r\n' +
'\r\n' +
'1\r\n' +
'00:00:01.600 --> 00:00:04.200\r\n' +
'English (US)\r\n' +
'\r\n' +
'2\r\n' +
'00:00:05.900 --> 00:00:07.999\r\n' +
'This is a subtitle in American English\r\n' +
'\r\n' +
'3\r\n' +
'00:00:10.000 --> 00:00:14.000\r\n' +
'Adding subtitles is very easy to do\r\n'
await testCaptionFile(server.url, caption1.captionPath, expected)
}
})
const objectStorage = new ObjectStorageCommand()
it('Should remove one caption', async function () {
this.timeout(30000)
before(async function () {
this.timeout(120000)
await servers[0].captions.delete({ videoId: videoUUID, language: 'ar' })
const configOverride = objectStorage.getDefaultMockConfig()
await objectStorage.prepareDefaultMockBuckets()
await waitJobs(servers)
})
await servers[0].kill()
await servers[0].run(configOverride)
it('Should only list the caption that was not deleted', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(1)
expect(body.data).to.have.lengthOf(1)
const { uuid } = await servers[0].videos.quickUpload({ name: 'object storage' })
videoUUID = uuid
const caption = body.data[0]
await waitJobs(servers)
})
expect(caption.language.id).to.equal('zh')
expect(caption.language.label).to.equal('Chinese')
expect(caption.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$'))
await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
}
})
it('Should create captions', async function () {
this.timeout(30000)
it('Should remove the video, and thus all video captions', async function () {
const video = await servers[0].videos.get({ id: videoUUID })
const { data: captions } = await servers[0].captions.list({ videoId: videoUUID })
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good1.vtt'
})
await servers[0].videos.remove({ id: videoUUID })
await servers[0].captions.add({
language: 'zh',
videoId: videoUUID,
fixture: 'subtitle-good2.vtt',
mimeType: 'application/octet-stream'
})
await checkVideoFilesWereRemoved({ server: servers[0], video, captions })
await waitJobs(servers)
})
it('Should have these captions in object storage', async function () {
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
{
const caption1 = body.data[0]
expect(caption1.language.id).to.equal('ar')
if (server === servers[0]) {
expectStartWith(caption1.fileUrl, objectStorage.getMockCaptionFileBaseUrl())
expect(caption1.captionPath).to.be.null
oldFileUrlsAr.push(caption1.fileUrl)
} else {
expect(caption1.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
expect(caption1.fileUrl).to.match(new RegExp(`^${server.url}/lazy-static/video-captions/${uuidRegex}-ar.vtt$`))
}
await testCaptionFile(caption1.fileUrl, 'Subtitle good 1.')
}
{
const caption2 = body.data[1]
expect(caption2.language.id).to.equal('zh')
if (server === servers[0]) {
expectStartWith(caption2.fileUrl, objectStorage.getMockCaptionFileBaseUrl())
expect(caption2.captionPath).to.be.null
oldFileUrlsZh.push(caption2.fileUrl)
}
await testCaptionFile(caption2.fileUrl, 'Subtitle good 2.')
}
}
await checkDirectoryIsEmpty(servers[0], 'captions')
})
it('Should replace an existing caption', async function () {
this.timeout(30000)
await servers[0].captions.add({
language: 'ar',
videoId: videoUUID,
fixture: 'subtitle-good.srt'
})
await waitJobs(servers)
// Cache invalidation
await wait(3000)
for (const url of oldFileUrlsAr) {
await makeRawRequest({ url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}
await checkDirectoryIsEmpty(servers[0], 'captions')
oldFileUrlsAr = []
for (const server of servers) {
const body = await server.captions.list({ videoId: videoUUID })
expect(body.total).to.equal(2)
expect(body.data).to.have.lengthOf(2)
const caption = body.data.find(c => c.language.id === 'ar')
if (server === servers[0]) {
expectStartWith(caption.fileUrl, objectStorage.getMockCaptionFileBaseUrl())
expect(caption.captionPath).to.be.null
oldFileUrlsAr.push(caption.fileUrl)
}
await testCaptionFile(caption.fileUrl, 'This is a subtitle in American English')
}
})
it('Should remove a caption', async function () {
this.timeout(30000)
await servers[0].captions.delete({ videoId: videoUUID, language: 'ar' })
await waitJobs(servers)
await checkDirectoryIsEmpty(servers[0], 'captions')
for (const url of oldFileUrlsAr) {
await makeRawRequest({ url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}
for (const url of oldFileUrlsZh) {
await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
}
})
it('Should remove the video, and thus all video captions', async function () {
await servers[0].videos.remove({ id: videoUUID })
await waitJobs(servers)
for (const url of oldFileUrlsZh) {
await makeRawRequest({ url, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}
})
after(async function () {
await objectStorage.cleanupMock()
})
})
after(async function () {

View file

@ -145,7 +145,7 @@ describe('Test video imports', function () {
`00:00:01.600 --> 00:00:04.200( position:\\d+% line:\\d+%)?[ \n]+English \\(US\\)[ \n]+` +
`00:00:05.900 --> 00:00:07.999( position:\\d+% line:\\d+%)?[ \n]+This is a subtitle in American English[ \n]+` +
`00:00:10.000 --> 00:00:14.000( position:\\d+% line:\\d+%)?[ \n]+Adding subtitles is very easy to do`
await testCaptionFile(servers[0].url, enCaption.captionPath, new RegExp(regex))
await testCaptionFile(enCaption.fileUrl, new RegExp(regex))
}
{
@ -160,7 +160,7 @@ describe('Test video imports', function () {
`00:00:05.900 --> 00:00:07.999( position:\\d+% line:\\d+%)?[ \n]+C'est un sous-titre français[ \n]+` +
`00:00:10.000 --> 00:00:14.000( position:\\d+% line:\\d+%)?[ \n]+Ajouter un sous-titre est vraiment facile`
await testCaptionFile(servers[0].url, frCaption.captionPath, new RegExp(regex))
await testCaptionFile(frCaption.fileUrl, new RegExp(regex))
}
})
@ -510,7 +510,7 @@ describe('Test video imports', function () {
`1\r?\n` +
`00:00:04.000 --> 00:00:09.000\r?\n` +
`January 1, 1994. The North American`
await testCaptionFile(server.url, captions[0].captionPath, new RegExp(str))
await testCaptionFile(captions[0].fileUrl, new RegExp(str))
}
}
})

View file

@ -11,6 +11,7 @@ import {
createMultipleServers,
doubleFollow,
makeGetRequest,
makeRawRequest,
PeerTubeServer,
sendRTMPStream,
setAccessTokensToServers,
@ -46,8 +47,15 @@ async function checkStoryboard (options: {
expect(storyboard.totalHeight).to.equal(spriteHeight * Math.max((tilesCount / 11), 1))
}
const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
expect(body.length).to.be.above(minSize)
{
const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
expect(body.length).to.be.above(minSize)
}
{
const { body } = await makeRawRequest({ url: storyboard.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
expect(body.length).to.be.above(minSize)
}
}
describe('Test video storyboard', function () {

View file

@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { VideoPrivacy } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
ObjectStorageCommand,
PeerTubeServer,
cleanupTests,
createMultipleServers,
@ -36,200 +38,255 @@ describe('Test video transcription', function () {
// ---------------------------------------------------------------------------
it('Should generate a transcription on request', async function () {
this.timeout(360000)
describe('Common on filesystem', function () {
await servers[0].config.disableTranscription()
it('Should generate a transcription on request', async function () {
this.timeout(360000)
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
await servers[0].config.disableTranscription()
await servers[0].config.enableTranscription()
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers)
await checkLanguage(servers, uuid, 'en')
await servers[0].config.enableTranscription()
await checkAutoCaption(servers, uuid)
})
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers)
await checkLanguage(servers, uuid, 'en')
it('Should run transcription on upload by default', async function () {
this.timeout(360000)
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkAutoCaption(servers, uuid)
await checkLanguage(servers, uuid, 'en')
})
it('Should run transcription on import by default', async function () {
this.timeout(360000)
const { video } = await servers[0].videoImports.importVideo({
attributes: {
privacy: VideoPrivacy.PUBLIC,
targetUrl: FIXTURE_URLS.transcriptionVideo,
language: undefined
}
await checkAutoCaption({ servers, uuid })
})
await waitJobs(servers)
await checkAutoCaption(servers, video.uuid)
await checkLanguage(servers, video.uuid, 'en')
})
it('Should run transcription on upload by default', async function () {
this.timeout(360000)
it('Should run transcription when live ended', async function () {
this.timeout(360000)
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
const { live, video } = await servers[0].live.quickCreate({
saveReplay: true,
permanentLive: false,
privacy: VideoPrivacy.PUBLIC
})
const ffmpegCommand = sendRTMPStream({
rtmpBaseUrl: live.rtmpUrl,
streamKey: live.streamKey,
fixtureName: join('transcription', 'videos', 'the_last_man_on_earth.mp4')
})
await servers[0].live.waitUntilPublished({ videoId: video.id })
await stopFfmpeg(ffmpegCommand)
await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id })
await waitJobs(servers)
await checkAutoCaption(servers, video.uuid, new RegExp('^WEBVTT\\n\\n00:\\d{2}.\\d{3} --> 00:'))
await checkLanguage(servers, video.uuid, 'en')
await servers[0].config.enableLive({ allowReplay: false })
await servers[0].config.disableTranscoding()
})
it('Should not run transcription if disabled by user', async function () {
this.timeout(120000)
{
const uuid = await uploadForTranscription(servers[0], { generateTranscription: false })
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
}
await checkAutoCaption({ servers, uuid })
await checkLanguage(servers, uuid, 'en')
})
it('Should run transcription on import by default', async function () {
this.timeout(360000)
{
const { video } = await servers[0].videoImports.importVideo({
attributes: {
privacy: VideoPrivacy.PUBLIC,
targetUrl: FIXTURE_URLS.transcriptionVideo,
generateTranscription: false
language: undefined
}
})
await waitJobs(servers)
await checkNoCaption(servers, video.uuid)
await checkLanguage(servers, video.uuid, null)
}
})
it('Should not run a transcription if the video does not contain audio', async function () {
this.timeout(120000)
const uuid = await uploadForTranscription(servers[0], { fixture: 'video_short_no_audio.mp4' })
await waitJobs(servers)
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
})
it('Should not replace an existing caption', async function () {
const uuid = await uploadForTranscription(servers[0])
await servers[0].captions.add({
language: 'en',
videoId: uuid,
fixture: 'subtitle-good1.vtt'
await checkAutoCaption({ servers, uuid: video.uuid })
await checkLanguage(servers, video.uuid, 'en')
})
const contentBefore = await getCaptionContent(servers[0], uuid, 'en')
await waitJobs(servers)
const contentAter = await getCaptionContent(servers[0], uuid, 'en')
it('Should run transcription when live ended', async function () {
this.timeout(360000)
expect(contentBefore).to.equal(contentAter)
})
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
it('Should run transcription after a video edition', async function () {
this.timeout(120000)
const { live, video } = await servers[0].live.quickCreate({
saveReplay: true,
permanentLive: false,
privacy: VideoPrivacy.PUBLIC
})
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableStudio()
const ffmpegCommand = sendRTMPStream({
rtmpBaseUrl: live.rtmpUrl,
streamKey: live.streamKey,
fixtureName: join('transcription', 'videos', 'the_last_man_on_earth.mp4')
})
await servers[0].live.waitUntilPublished({ videoId: video.id })
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await stopFfmpeg(ffmpegCommand)
await checkAutoCaption(servers, uuid)
const oldContent = await getCaptionContent(servers[0], uuid, 'en')
await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id })
await waitJobs(servers)
await checkAutoCaption({
servers,
uuid: video.uuid,
captionContains: new RegExp('^WEBVTT\\n\\n00:\\d{2}.\\d{3} --> 00:')
})
await checkLanguage(servers, video.uuid, 'en')
await servers[0].videoStudio.createEditionTasks({
videoId: uuid,
tasks: [
{
name: 'cut' as 'cut',
options: { start: 1 }
}
]
await servers[0].config.enableLive({ allowReplay: false })
await servers[0].config.disableTranscoding()
})
await waitJobs(servers)
await checkAutoCaption(servers, uuid)
it('Should not run transcription if disabled by user', async function () {
this.timeout(120000)
const newContent = await getCaptionContent(servers[0], uuid, 'en')
expect(oldContent).to.not.equal(newContent)
})
{
const uuid = await uploadForTranscription(servers[0], { generateTranscription: false })
it('Should not run transcription after video edition if the subtitle has not been auto generated', async function () {
this.timeout(120000)
await waitJobs(servers)
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
}
const uuid = await uploadForTranscription(servers[0], { language: 'en' })
await waitJobs(servers)
{
const { video } = await servers[0].videoImports.importVideo({
attributes: {
privacy: VideoPrivacy.PUBLIC,
targetUrl: FIXTURE_URLS.transcriptionVideo,
generateTranscription: false
}
})
await servers[0].captions.add({ language: 'en', videoId: uuid, fixture: 'subtitle-good1.vtt' })
const oldContent = await getCaptionContent(servers[0], uuid, 'en')
await servers[0].videoStudio.createEditionTasks({
videoId: uuid,
tasks: [
{
name: 'cut' as 'cut',
options: { start: 1 }
}
]
await waitJobs(servers)
await checkNoCaption(servers, video.uuid)
await checkLanguage(servers, video.uuid, null)
}
})
await waitJobs(servers)
it('Should not run a transcription if the video does not contain audio', async function () {
this.timeout(120000)
const newContent = await getCaptionContent(servers[0], uuid, 'en')
expect(oldContent).to.equal(newContent)
const uuid = await uploadForTranscription(servers[0], { fixture: 'video_short_no_audio.mp4' })
await waitJobs(servers)
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
})
it('Should not replace an existing caption', async function () {
const uuid = await uploadForTranscription(servers[0])
await servers[0].captions.add({
language: 'en',
videoId: uuid,
fixture: 'subtitle-good1.vtt'
})
const contentBefore = await getCaptionContent(servers[0], uuid, 'en')
await waitJobs(servers)
const contentAter = await getCaptionContent(servers[0], uuid, 'en')
expect(contentBefore).to.equal(contentAter)
})
it('Should run transcription after a video edition', async function () {
this.timeout(120000)
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableStudio()
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkAutoCaption({ servers, uuid })
const oldContent = await getCaptionContent(servers[0], uuid, 'en')
await servers[0].videoStudio.createEditionTasks({
videoId: uuid,
tasks: [
{
name: 'cut' as 'cut',
options: { start: 1 }
}
]
})
await waitJobs(servers)
await checkAutoCaption({ servers, uuid })
const newContent = await getCaptionContent(servers[0], uuid, 'en')
expect(oldContent).to.not.equal(newContent)
})
it('Should not run transcription after video edition if the subtitle has not been auto generated', async function () {
this.timeout(120000)
const uuid = await uploadForTranscription(servers[0], { language: 'en' })
await waitJobs(servers)
await servers[0].captions.add({ language: 'en', videoId: uuid, fixture: 'subtitle-good1.vtt' })
const oldContent = await getCaptionContent(servers[0], uuid, 'en')
await servers[0].videoStudio.createEditionTasks({
videoId: uuid,
tasks: [
{
name: 'cut' as 'cut',
options: { start: 1 }
}
]
})
await waitJobs(servers)
const newContent = await getCaptionContent(servers[0], uuid, 'en')
expect(oldContent).to.equal(newContent)
})
it('Should run transcription with HLS only and audio splitted', async function () {
this.timeout(360000)
await servers[0].config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true })
const uuid = await uploadForTranscription(servers[0], { generateTranscription: false })
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers)
await checkAutoCaption({ servers, uuid })
await checkLanguage(servers, uuid, 'en')
})
})
it('Should run transcription with HLS only and audio splitted', async function () {
this.timeout(360000)
describe('On object storage', async function () {
if (areMockObjectStorageTestsDisabled()) return
await servers[0].config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true })
const objectStorage = new ObjectStorageCommand()
const uuid = await uploadForTranscription(servers[0], { generateTranscription: false })
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
before(async function () {
this.timeout(120000)
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers)
const configOverride = objectStorage.getDefaultMockConfig()
await objectStorage.prepareDefaultMockBuckets()
await checkAutoCaption(servers, uuid)
await checkLanguage(servers, uuid, 'en')
await servers[0].kill()
await servers[0].run(configOverride)
})
it('Should generate a transcription on request', async function () {
this.timeout(360000)
await servers[0].config.disableTranscription()
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
await servers[0].config.enableTranscription()
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers)
await checkLanguage(servers, uuid, 'en')
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
})
it('Should run transcription on upload by default', async function () {
this.timeout(360000)
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
await checkLanguage(servers, uuid, 'en')
})
after(async function () {
await objectStorage.cleanupMock()
})
})
after(async function () {

View file

@ -18,7 +18,12 @@ import { checkDirectoryIsEmpty } from '@tests/shared/directories.js'
import { join } from 'path'
import { expectStartWith } from '../shared/checks.js'
async function checkFiles (origin: PeerTubeServer, video: VideoDetails, objectStorage?: ObjectStorageCommand) {
async function checkFiles (options: {
origin: PeerTubeServer
video: VideoDetails
objectStorage?: ObjectStorageCommand
}) {
const { origin, video, objectStorage } = options
// Web videos
for (const file of video.files) {
@ -62,6 +67,21 @@ async function checkFiles (origin: PeerTubeServer, video: VideoDetails, objectSt
expectStartWith(source.fileDownloadUrl, origin.url)
}
}
// Captions
{
const start = objectStorage
? objectStorage.getMockCaptionFileBaseUrl()
: origin.url
const { data: captions } = await origin.captions.list({ videoId: video.uuid })
for (const caption of captions) {
expectStartWith(caption.fileUrl, start)
await makeRawRequest({ url: caption.fileUrl, token: origin.accessToken, expectedStatus: HttpStatusCode.OK_200 })
}
}
}
describe('Test create move video storage job CLI', function () {
@ -86,6 +106,10 @@ describe('Test create move video storage job CLI', function () {
for (let i = 0; i < 3; i++) {
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' + i })
await servers[0].captions.add({ language: 'ar', videoId: uuid, fixture: 'subtitle-good1.vtt' })
await servers[0].captions.add({ language: 'zh', videoId: uuid, fixture: 'subtitle-good1.vtt' })
uuids.push(uuid)
}
@ -107,12 +131,12 @@ describe('Test create move video storage job CLI', function () {
for (const server of servers) {
const video = await server.videos.get({ id: uuids[1] })
await checkFiles(servers[0], video, objectStorage)
await checkFiles({ origin: servers[0], video, objectStorage })
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video)
await checkFiles({ origin: servers[0], video })
}
}
})
@ -128,7 +152,7 @@ describe('Test create move video storage job CLI', function () {
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video, objectStorage)
await checkFiles({ origin: servers[0], video, objectStorage })
}
}
})
@ -164,12 +188,12 @@ describe('Test create move video storage job CLI', function () {
for (const server of servers) {
const video = await server.videos.get({ id: uuids[1] })
await checkFiles(servers[0], video)
await checkFiles({ origin: servers[0], video })
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video, objectStorage)
await checkFiles({ origin: servers[0], video, objectStorage })
}
}
})
@ -185,7 +209,7 @@ describe('Test create move video storage job CLI', function () {
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video)
await checkFiles({ origin: servers[0], video })
}
}
})

View file

@ -48,7 +48,7 @@ describe('Test CLI wrapper', function () {
describe('Authentication and instance selection', function () {
it('Should get an access token', async function () {
const stdout = await cliCommand.execWithEnv(`${cmd} token --url ${server.url} --username user_1 --password super_password`)
const { stdout } = await cliCommand.execWithEnv(`${cmd} token --url ${server.url} --username user_1 --password super_password`)
const token = stdout.trim()
const body = await server.users.getMyInfo({ token })

View file

@ -267,27 +267,46 @@ describe('Test prune storage CLI', function () {
if (areMockObjectStorageTestsDisabled()) return
const videos: string[] = []
const objectStorage = new ObjectStorageCommand()
const videoFileUrls: { [ uuid: string ]: string[] } = {}
const sourceFileUrls: { [ uuid: string ]: string } = {}
const captionFileUrls: { [ uuid: string ]: { [ language: string ]: string } } = {}
let sqlCommand: SQLCommand
let rootId: number
let captionVideoId: number
async function execPruneStorage () {
const env = servers[0].cli.getEnv(objectStorage.getDefaultMockConfig({ proxifyPrivateFiles: false }))
await servers[0].cli.execWithEnv(`${env} npm run prune-storage -- -y`)
}
async function checkVideosFiles (uuids: string[], expectedStatus: HttpStatusCodeType) {
for (const uuid of uuids) {
const video = await servers[0].videos.getWithToken({ id: uuid })
for (const file of getAllFiles(video)) {
await makeRawRequest({ url: file.fileUrl, token: servers[0].accessToken, expectedStatus })
for (const url of videoFileUrls[uuid]) {
await makeRawRequest({ url, token: servers[0].accessToken, expectedStatus })
}
const source = await servers[0].videos.getSource({ id: uuid })
await makeRawRequest({ url: source.fileDownloadUrl, redirects: 1, token: servers[0].accessToken, expectedStatus })
await makeRawRequest({ url: sourceFileUrls[uuid], redirects: 1, token: servers[0].accessToken, expectedStatus })
}
}
async function checkCaptionFiles (uuids: string[], languages: string[], expectedStatus: HttpStatusCodeType) {
for (const uuid of uuids) {
for (const language of languages) {
await makeRawRequest({ url: captionFileUrls[uuid][language], token: servers[0].accessToken, expectedStatus })
}
}
}
async function checkUserExport (expectedStatus: HttpStatusCodeType) {
const { data } = await servers[0].userExports.list({ userId: rootId })
await makeRawRequest({ url: data[0].privateDownloadUrl, redirects: 1, expectedStatus })
const { data: userExports } = await servers[0].userExports.list({ userId: rootId })
const userExportUrl = userExports[0].privateDownloadUrl
await makeRawRequest({ url: userExportUrl, token: servers[0].accessToken, redirects: 1, expectedStatus })
}
before(async function () {
@ -297,7 +316,7 @@ describe('Test prune storage CLI', function () {
await objectStorage.prepareDefaultMockBuckets()
await servers[0].run(objectStorage.getDefaultMockConfig())
await servers[0].run(objectStorage.getDefaultMockConfig({ proxifyPrivateFiles: false }))
{
const { uuid } = await servers[0].videos.quickUpload({ name: 's3 video 1', privacy: VideoPrivacy.PUBLIC })
@ -310,7 +329,13 @@ describe('Test prune storage CLI', function () {
}
{
const { uuid } = await servers[0].videos.quickUpload({ name: 's3 video 3', privacy: VideoPrivacy.PRIVATE })
const { id, uuid } = await servers[0].videos.quickUpload({ name: 's3 video 3', privacy: VideoPrivacy.PRIVATE })
await servers[0].captions.add({ language: 'ar', videoId: uuid, fixture: 'subtitle-good1.vtt' })
await servers[0].captions.add({ language: 'zh', videoId: uuid, fixture: 'subtitle-good1.vtt' })
captionVideoId = id
videos.push(uuid)
}
@ -321,33 +346,62 @@ describe('Test prune storage CLI', function () {
await servers[0].userExports.request({ userId: rootId, withVideoFiles: false })
await waitJobs([ servers[0] ])
// Grab all file URLs
for (const uuid of videos) {
const video = await servers[0].videos.getWithToken({ id: uuid })
videoFileUrls[uuid] = getAllFiles(video).map(f => f.fileUrl)
const source = await servers[0].videos.getSource({ id: uuid })
sourceFileUrls[uuid] = source.fileDownloadUrl
const { data: captions } = await servers[0].captions.list({ videoId: uuid, token: servers[0].accessToken })
if (!captionFileUrls[uuid]) captionFileUrls[uuid] = {}
for (const caption of captions) {
captionFileUrls[uuid][caption.language.id] = caption.fileUrl
}
}
})
it('Should have the files on object storage', async function () {
await checkVideosFiles(videos, HttpStatusCode.OK_200)
await checkUserExport(HttpStatusCode.OK_200)
await checkCaptionFiles([ videos[2] ], [ 'ar', 'zh' ], HttpStatusCode.OK_200)
})
it('Should run prune-storage script on videos', async function () {
await sqlCommand.setVideoFileStorageOf(videos[1], FileStorage.FILE_SYSTEM)
await sqlCommand.setVideoFileStorageOf(videos[2], FileStorage.FILE_SYSTEM)
await execPruneStorage()
await checkVideosFiles([ videos[1], videos[2] ], HttpStatusCode.NOT_FOUND_404)
await checkVideosFiles([ videos[0] ], HttpStatusCode.OK_200)
await checkUserExport(HttpStatusCode.OK_200)
await checkCaptionFiles([ videos[2] ], [ 'ar', 'zh' ], HttpStatusCode.OK_200)
})
it('Should run prune-storage script on exports', async function () {
await sqlCommand.setUserExportStorageOf(rootId, FileStorage.FILE_SYSTEM)
await checkVideosFiles([ videos[1], videos[2] ], HttpStatusCode.NOT_FOUND_404)
await checkVideosFiles([ videos[0] ], HttpStatusCode.OK_200)
await execPruneStorage()
await checkUserExport(HttpStatusCode.NOT_FOUND_404)
await checkCaptionFiles([ videos[2] ], [ 'ar', 'zh' ], HttpStatusCode.OK_200)
})
it('Should run prune-storage script on captions', async function () {
await sqlCommand.setCaptionStorageOf(captionVideoId, 'zh', FileStorage.FILE_SYSTEM)
await execPruneStorage()
await checkCaptionFiles([ videos[2] ], [ 'ar' ], HttpStatusCode.OK_200)
await checkCaptionFiles([ videos[2] ], [ 'zh' ], HttpStatusCode.NOT_FOUND_404)
})
after(async function () {
await objectStorage.cleanupMock()
await sqlCommand.cleanup()
})
})

View file

@ -41,6 +41,9 @@ describe('Update object storage URL CLI', function () {
const video = await server.videos.quickUpload({ name: 'video' })
uuid = video.uuid
await server.captions.add({ language: 'ar', videoId: uuid, fixture: 'subtitle-good1.vtt' })
await server.captions.add({ language: 'zh', videoId: uuid, fixture: 'subtitle-good1.vtt' })
await waitJobs([ server ])
})
@ -99,6 +102,16 @@ describe('Update object storage URL CLI', function () {
return [ source.fileUrl ]
}
})
await check({
baseUrl: objectStorage.getMockCaptionFileBaseUrl(),
newBaseUrl: 'https://captions.example.com/',
urlGetter: async video => {
const { data } = await server.captions.list({ videoId: video.uuid })
return data.map(c => c.fileUrl)
}
})
})
it('Should update user export URLs', async function () {

View file

@ -2,7 +2,9 @@
import { wait } from '@peertube/peertube-core-utils'
import { RunnerJobState } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
ObjectStorageCommand,
PeerTubeServer,
cleanupTests,
createMultipleServers,
@ -41,82 +43,115 @@ describe('Test transcription in peertube-runner program', function () {
describe('Running transcription', function () {
it('Should run transcription on classic file', async function () {
this.timeout(360000)
describe('Common on filesystem', function () {
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers, { runnerJobs: true })
it('Should run transcription on classic file', async function () {
this.timeout(360000)
await checkAutoCaption(servers, uuid)
await checkLanguage(servers, uuid, 'en')
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers, { runnerJobs: true })
await checkAutoCaption({ servers, uuid })
await checkLanguage(servers, uuid, 'en')
})
it('Should run transcription on HLS with audio separated', async function () {
await servers[0].config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true })
const uuid = await uploadForTranscription(servers[0], { generateTranscription: false })
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers, { runnerJobs: true })
await checkAutoCaption({ servers, uuid })
await checkLanguage(servers, uuid, 'en')
})
it('Should not run transcription on video without audio stream', async function () {
this.timeout(360000)
const uuid = await uploadForTranscription(servers[0], { fixture: 'video_short_no_audio.mp4' })
await waitJobs(servers)
let continueWhile = true
while (continueWhile) {
await wait(500)
const { data } = await servers[0].runnerJobs.list({ stateOneOf: [ RunnerJobState.ERRORED ] })
continueWhile = !data.some(j => j.type === 'video-transcription')
}
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
})
})
it('Should run transcription on HLS with audio separated', async function () {
await servers[0].config.enableMinimumTranscoding({ hls: true, webVideo: false, splitAudioAndVideo: true })
describe('On object storage', function () {
if (areMockObjectStorageTestsDisabled()) return
const uuid = await uploadForTranscription(servers[0], { generateTranscription: false })
await waitJobs(servers)
await checkLanguage(servers, uuid, null)
const objectStorage = new ObjectStorageCommand()
await servers[0].captions.runGenerate({ videoId: uuid })
await waitJobs(servers, { runnerJobs: true })
before(async function () {
this.timeout(120000)
await checkAutoCaption(servers, uuid)
await checkLanguage(servers, uuid, 'en')
const configOverride = objectStorage.getDefaultMockConfig()
await objectStorage.prepareDefaultMockBuckets()
await servers[0].kill()
await servers[0].run(configOverride)
})
it('Should run transcription and upload it on object storage', async function () {
this.timeout(360000)
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers, { runnerJobs: true })
await checkAutoCaption({ servers, uuid, objectStorageBaseUrl: objectStorage.getMockCaptionFileBaseUrl() })
await checkLanguage(servers, uuid, 'en')
})
after(async function () {
await objectStorage.cleanupMock()
})
})
it('Should not run transcription on video without audio stream', async function () {
this.timeout(360000)
describe('When transcription is not enabled in runner', function () {
const uuid = await uploadForTranscription(servers[0], { fixture: 'video_short_no_audio.mp4' })
await waitJobs(servers)
let continueWhile = true
while (continueWhile) {
before(async function () {
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
peertubeRunner.kill()
await wait(500)
const { data } = await servers[0].runnerJobs.list({ stateOneOf: [ RunnerJobState.ERRORED ] })
const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken()
await peertubeRunner.runServer({ jobType: 'live-rtmp-hls-transcoding' })
await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' })
})
continueWhile = !data.some(j => j.type === 'video-transcription')
}
it('Should not run transcription', async function () {
this.timeout(60000)
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
})
})
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await wait(2000)
describe('When transcription is not enabled in runner', function () {
const { data } = await servers[0].runnerJobs.list({ stateOneOf: [ RunnerJobState.PENDING ] })
expect(data.some(j => j.type === 'video-transcription')).to.be.true
before(async function () {
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
peertubeRunner.kill()
await wait(500)
const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken()
await peertubeRunner.runServer({ jobType: 'live-rtmp-hls-transcoding' })
await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' })
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
})
})
it('Should not run transcription', async function () {
this.timeout(60000)
describe('Check cleanup', function () {
const uuid = await uploadForTranscription(servers[0])
await waitJobs(servers)
await wait(2000)
const { data } = await servers[0].runnerJobs.list({ stateOneOf: [ RunnerJobState.PENDING ] })
expect(data.some(j => j.type === 'video-transcription')).to.be.true
await checkNoCaption(servers, uuid)
await checkLanguage(servers, uuid, null)
})
})
describe('Check cleanup', function () {
it('Should have an empty cache directory', async function () {
await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner, 'transcription')
it('Should have an empty cache directory', async function () {
await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner, 'transcription')
})
})
})

View file

@ -1,11 +1,9 @@
import { expect } from 'chai'
import request from 'supertest'
import { HttpStatusCode } from '@peertube/peertube-models'
import { expect } from 'chai'
import { makeRawRequest } from '../../../server-commands/src/requests/requests.js'
async function testCaptionFile (url: string, captionPath: string, toTest: RegExp | string) {
const res = await request(url)
.get(captionPath)
.expect(HttpStatusCode.OK_200)
export async function testCaptionFile (fileUrl: string, toTest: RegExp | string) {
const res = await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.OK_200 })
if (toTest instanceof RegExp) {
expect(res.text).to.match(toTest)
@ -13,9 +11,3 @@ async function testCaptionFile (url: string, captionPath: string, toTest: RegExp
expect(res.text).to.contain(toTest)
}
}
// ---------------------------------------------------------------------------
export {
testCaptionFile
}

View file

@ -63,7 +63,6 @@ export class SQLCommand {
await this.updateQuery(
`UPDATE "videoFile" SET storage = :storage ` +
`WHERE "videoId" IN (SELECT id FROM "video" WHERE uuid = :uuid) OR ` +
// eslint-disable-next-line max-len
`"videoStreamingPlaylistId" IN (` +
`SELECT "videoStreamingPlaylist".id FROM "videoStreamingPlaylist" ` +
`INNER JOIN video ON video.id = "videoStreamingPlaylist"."videoId" AND "video".uuid = :uuid` +
@ -71,6 +70,12 @@ export class SQLCommand {
{ storage, uuid }
)
await this.updateQuery(
`UPDATE "videoStreamingPlaylist" SET storage = :storage ` +
`WHERE "videoId" IN (SELECT id FROM "video" WHERE uuid = :uuid)`,
{ storage, uuid }
)
await this.updateQuery(
`UPDATE "videoSource" SET storage = :storage WHERE "videoId" IN (SELECT id FROM "video" WHERE uuid = :uuid)`,
{ storage, uuid }
@ -81,6 +86,15 @@ export class SQLCommand {
await this.updateQuery(`UPDATE "userExport" SET storage = :storage WHERE "userId" = :userId`, { storage, userId })
}
async setCaptionStorageOf (videoId: number, language: string, storage: FileStorageType) {
await this.updateQuery(
`UPDATE "videoCaption" SET storage = :storage WHERE "videoId" = :videoId AND language = :language`,
{ storage, videoId, language }
)
}
// ---------------------------------------------------------------------------
async setUserEmail (username: string, email: string) {
await this.updateQuery(`UPDATE "user" SET email = :email WHERE "username" = :username`, { email, username })
}

View file

@ -9,6 +9,7 @@ import { ensureDir, pathExists } from 'fs-extra/esm'
import { join } from 'path'
import { testCaptionFile } from './captions.js'
import { FIXTURE_URLS } from './fixture-urls.js'
import { expectStartWith } from './checks.js'
type CustomModelName = 'tiny.pt' | 'faster-whisper-tiny'
@ -29,11 +30,23 @@ export function getCustomModelPath (modelName: CustomModelName) {
// ---------------------------------------------------------------------------
export async function checkAutoCaption (
servers: PeerTubeServer[],
uuid: string,
captionContains = new RegExp('^WEBVTT\\n\\n00:00.\\d{3} --> 00:')
) {
export async function checkAutoCaption (options: {
servers: PeerTubeServer[]
uuid: string
captionContains?: RegExp
rootServer?: PeerTubeServer
objectStorageBaseUrl?: string
}) {
const {
servers,
rootServer = servers[0],
uuid,
captionContains = new RegExp('^WEBVTT\\n\\n00:00.\\d{3} --> 00:'),
objectStorageBaseUrl
} = options
for (const server of servers) {
const body = await server.captions.list({ videoId: uuid })
expect(body.total).to.equal(1)
@ -44,9 +57,11 @@ export async function checkAutoCaption (
expect(caption.language.label).to.equal('English')
expect(caption.automaticallyGenerated).to.be.true
{
await testCaptionFile(server.url, caption.captionPath, captionContains)
if (objectStorageBaseUrl && server === rootServer) {
expectStartWith(caption.fileUrl, objectStorageBaseUrl)
}
await testCaptionFile(caption.fileUrl, captionContains)
}
}

View file

@ -6,5 +6,5 @@ block title
block content
p.
Welcome to #[a(href=WEBSERVER.URL) #{instanceName}]. Your username is: #{username}.
Please set your password by following #[a(href=createPasswordUrl) this link]: #[a(href=createPasswordUrl) #{createPasswordUrl}]
(this link will expire within seven days).
Please set your password by following #[a(href=createPasswordUrl) this link]: #[a(href=createPasswordUrl) #{createPasswordUrl}]
(this link will expire within seven days).

View file

@ -7,7 +7,7 @@ import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { buildNewFile, createVideoSource } from '@server/lib/video-file.js'
import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { buildMoveVideoJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
@ -181,7 +181,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
]
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
jobs.push(await buildMoveJob({ video, isNewVideo: false, previousVideoState: undefined, type: 'move-to-object-storage' }))
jobs.push(await buildMoveVideoJob({ video, isNewVideo: false, previousVideoState: undefined, type: 'move-to-object-storage' }))
}
if (video.state === VideoState.TO_TRANSCODE) {

View file

@ -72,6 +72,7 @@ function checkMissedConfig () {
'object_storage.streaming_playlists.prefix', 'object_storage.streaming_playlists.base_url', 'object_storage.web_videos.bucket_name',
'object_storage.web_videos.prefix', 'object_storage.web_videos.base_url', 'object_storage.original_video_files.bucket_name',
'object_storage.original_video_files.prefix', 'object_storage.original_video_files.base_url', 'object_storage.max_request_attempts',
'object_storage.captions.bucket_name', 'object_storage.captions.prefix', 'object_storage.captions.base_url',
'theme.default',
'feeds.videos.count', 'feeds.comments.count',
'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url',

View file

@ -170,6 +170,11 @@ const CONFIG = {
BUCKET_NAME: config.get<string>('object_storage.original_video_files.bucket_name'),
PREFIX: config.get<string>('object_storage.original_video_files.prefix'),
BASE_URL: config.get<string>('object_storage.original_video_files.base_url')
},
CAPTIONS: {
BUCKET_NAME: config.get<string>('object_storage.captions.bucket_name'),
PREFIX: config.get<string>('object_storage.captions.prefix'),
BASE_URL: config.get<string>('object_storage.captions.base_url')
}
},
WEBSERVER: {

View file

@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 870
export const LAST_MIGRATION_VERSION = 875
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,32 @@
import { FileStorage } from '@peertube/peertube-models'
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
{
await utils.queryInterface.addColumn('videoCaption', 'storage', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: FileStorage.FILE_SYSTEM
}, { transaction })
await utils.queryInterface.changeColumn('videoCaption', 'storage', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: null
}, { transaction })
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down, up
}

View file

@ -40,7 +40,7 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
const video = await VideoModel.loadFull(videoCaption.videoId)
if (!video) return undefined
const remoteUrl = videoCaption.getFileUrl(video)
const remoteUrl = videoCaption.getOriginFileUrl(video)
const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.filename)
try {

View file

@ -1,11 +1,13 @@
import { FileStorage, MoveStoragePayload, VideoStateType } from '@peertube/peertube-models'
import { FileStorage, isMoveCaptionPayload, isMoveVideoStoragePayload, MoveStoragePayload, VideoStateType } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { updateTorrentMetadata } from '@server/helpers/webtorrent.js'
import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants.js'
import {
makeCaptionFileAvailable,
makeHLSFileAvailable,
makeOriginalFileAvailable,
makeWebVideoFileAvailable,
removeCaptionObjectStorage,
removeHLSFileObjectStorageByFilename,
removeHLSObjectStorage,
removeOriginalFileObjectStorage,
@ -14,36 +16,54 @@ import {
import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { moveToFailedMoveToFileSystemState, moveToNextState } from '@server/lib/video-state.js'
import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { Job } from 'bullmq'
import { join } from 'path'
import { moveToJob, onMoveToStorageFailure } from './shared/move-video.js'
import { moveCaptionToStorageJob } from './shared/move-caption.js'
import { moveVideoToStorageJob, onMoveVideoToStorageFailure } from './shared/move-video.js'
const lTagsBase = loggerTagsFactory('move-file-system')
export async function processMoveToFileSystem (job: Job) {
const payload = job.data as MoveStoragePayload
logger.info('Moving video %s to file system in job %s.', payload.videoUUID, job.id)
await moveToJob({
jobId: job.id,
videoUUID: payload.videoUUID,
loggerTags: lTagsBase().tags,
if (isMoveVideoStoragePayload(payload)) { // Move all video related files
logger.info('Moving video %s to file system in job %s.', payload.videoUUID, job.id)
moveWebVideoFiles,
moveHLSFiles,
moveVideoSourceFile,
await moveVideoToStorageJob({
jobId: job.id,
videoUUID: payload.videoUUID,
loggerTags: lTagsBase().tags,
doAfterLastMove: video => doAfterLastMove({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo }),
moveToFailedState: moveToFailedMoveToFileSystemState
})
moveWebVideoFiles,
moveHLSFiles,
moveVideoSourceFile,
moveCaptionFiles,
doAfterLastMove: video => doAfterLastMove({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo }),
moveToFailedState: moveToFailedMoveToFileSystemState
})
} else if (isMoveCaptionPayload(payload)) { // Only caption file
logger.info(`Moving video caption ${payload.captionId} to file system in job ${job.id}.`)
await moveCaptionToStorageJob({
jobId: job.id,
captionId: payload.captionId,
loggerTags: lTagsBase().tags,
moveCaptionFiles
})
} else {
throw new Error('Unknown payload type')
}
}
export async function onMoveToFileSystemFailure (job: Job, err: any) {
const payload = job.data as MoveStoragePayload
await onMoveToStorageFailure({
if (!isMoveVideoStoragePayload(payload)) return
await onMoveVideoToStorageFailure({
videoUUID: payload.videoUUID,
err,
lTags: lTagsBase(),
@ -130,6 +150,28 @@ async function onVideoFileMoved (options: {
await objetStorageRemover()
}
// ---------------------------------------------------------------------------
async function moveCaptionFiles (captions: MVideoCaption[]) {
for (const caption of captions) {
if (caption.storage === FileStorage.FILE_SYSTEM) continue
await makeCaptionFileAvailable(caption.filename, caption.getFSPath())
const oldFileUrl = caption.fileUrl
caption.fileUrl = null
caption.storage = FileStorage.FILE_SYSTEM
await caption.save()
logger.debug('Removing caption file %s because it\'s now on file system', oldFileUrl, lTagsBase())
await removeCaptionObjectStorage(caption)
}
}
// ---------------------------------------------------------------------------
async function doAfterLastMove (options: {
video: MVideoWithAllFiles
previousVideoState: VideoStateType

View file

@ -1,41 +1,63 @@
import { FileStorage, MoveStoragePayload, VideoStateType } from '@peertube/peertube-models'
import { FileStorage, isMoveCaptionPayload, isMoveVideoStoragePayload, MoveStoragePayload, VideoStateType } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { updateTorrentMetadata } from '@server/helpers/webtorrent.js'
import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants.js'
import { storeHLSFileFromFilename, storeOriginalVideoFile, storeWebVideoFile } from '@server/lib/object-storage/index.js'
import { storeHLSFileFromFilename, storeOriginalVideoFile, storeVideoCaption, storeWebVideoFile } from '@server/lib/object-storage/index.js'
import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state.js'
import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { Job } from 'bullmq'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { moveToJob, onMoveToStorageFailure } from './shared/move-video.js'
import { moveCaptionToStorageJob } from './shared/move-caption.js'
import { moveVideoToStorageJob, onMoveVideoToStorageFailure } from './shared/move-video.js'
const lTagsBase = loggerTagsFactory('move-object-storage')
export async function processMoveToObjectStorage (job: Job) {
const payload = job.data as MoveStoragePayload
logger.info('Moving video %s to object storage in job %s.', payload.videoUUID, job.id)
await moveToJob({
jobId: job.id,
videoUUID: payload.videoUUID,
loggerTags: lTagsBase().tags,
if (isMoveVideoStoragePayload(payload)) { // Move all video related files
logger.info('Moving video %s to object storage in job %s.', payload.videoUUID, job.id)
moveWebVideoFiles,
moveHLSFiles,
moveVideoSourceFile,
doAfterLastMove: video => doAfterLastMove({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo }),
moveToFailedState: moveToFailedMoveToObjectStorageState
})
await moveVideoToStorageJob({
jobId: job.id,
videoUUID: payload.videoUUID,
loggerTags: lTagsBase().tags,
moveWebVideoFiles,
moveHLSFiles,
moveVideoSourceFile,
moveCaptionFiles,
doAfterLastMove: video => {
return doAfterLastVideoMove({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo })
},
moveToFailedState: moveToFailedMoveToObjectStorageState
})
} else if (isMoveCaptionPayload(payload)) { // Only caption file
logger.info(`Moving video caption ${payload.captionId} to object storage in job ${job.id}.`)
await moveCaptionToStorageJob({
jobId: job.id,
captionId: payload.captionId,
loggerTags: lTagsBase().tags,
moveCaptionFiles
})
} else {
throw new Error('Unknown payload type')
}
}
export async function onMoveToObjectStorageFailure (job: Job, err: any) {
const payload = job.data as MoveStoragePayload
await onMoveToStorageFailure({
if (!isMoveVideoStoragePayload(payload)) return
await onMoveVideoToStorageFailure({
videoUUID: payload.videoUUID,
err,
lTags: lTagsBase(),
@ -60,6 +82,27 @@ async function moveVideoSourceFile (source: MVideoSource) {
await remove(sourcePath)
}
// ---------------------------------------------------------------------------
async function moveCaptionFiles (captions: MVideoCaption[]) {
for (const caption of captions) {
if (caption.storage !== FileStorage.FILE_SYSTEM) continue
const captionPath = caption.getFSPath()
const fileUrl = await storeVideoCaption(captionPath, caption.filename)
caption.storage = FileStorage.OBJECT_STORAGE
caption.fileUrl = fileUrl
await caption.save()
logger.debug(`Removing video caption file ${captionPath} because it's now on object storage`, lTagsBase())
await remove(captionPath)
}
}
// ---------------------------------------------------------------------------
async function moveWebVideoFiles (video: MVideoWithAllFiles) {
for (const file of video.VideoFiles) {
if (file.storage !== FileStorage.FILE_SYSTEM) continue
@ -110,7 +153,9 @@ async function onVideoFileMoved (options: {
await remove(oldPath)
}
async function doAfterLastMove (options: {
// ---------------------------------------------------------------------------
async function doAfterLastVideoMove (options: {
video: MVideoWithAllFiles
previousVideoState: VideoStateType
isNewVideo: boolean

View file

@ -0,0 +1,48 @@
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoCaption } from '@server/types/models/index.js'
export async function moveCaptionToStorageJob (options: {
jobId: string
captionId: number
loggerTags: (number | string)[]
moveCaptionFiles: (captions: MVideoCaption[]) => Promise<void>
}) {
const {
jobId,
loggerTags,
captionId,
moveCaptionFiles
} = options
const lTagsBase = loggerTagsFactory(...loggerTags)
const caption = await VideoCaptionModel.loadWithVideo(captionId)
if (!caption) {
logger.info(`Can't process job ${jobId}, caption does not exist anymore.`, lTagsBase())
return
}
const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(caption.Video.uuid)
try {
await moveCaptionFiles([ caption ])
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const videoFull = await VideoModel.loadFull(caption.Video.id, t)
await federateVideoIfNeeded(videoFull, false, t)
})
})
} finally {
fileMutexReleaser()
}
}

View file

@ -1,12 +1,13 @@
import { LoggerTags, logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoWithAllFiles } from '@server/types/models/index.js'
import { MVideoCaption, MVideoWithAllFiles } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
export async function moveToJob (options: {
export async function moveVideoToStorageJob (options: {
jobId: string
videoUUID: string
loggerTags: (number | string)[]
@ -14,6 +15,8 @@ export async function moveToJob (options: {
moveWebVideoFiles: (video: MVideoWithAllFiles) => Promise<void>
moveHLSFiles: (video: MVideoWithAllFiles) => Promise<void>
moveVideoSourceFile: (source: MVideoSource) => Promise<void>
moveCaptionFiles: (captions: MVideoCaption[]) => Promise<void>
moveToFailedState: (video: MVideoWithAllFiles) => Promise<void>
doAfterLastMove: (video: MVideoWithAllFiles) => Promise<void>
}) {
@ -24,6 +27,7 @@ export async function moveToJob (options: {
moveVideoSourceFile,
moveHLSFiles,
moveWebVideoFiles,
moveCaptionFiles,
moveToFailedState,
doAfterLastMove
} = options
@ -62,6 +66,13 @@ export async function moveToJob (options: {
await moveHLSFiles(video)
}
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
if (captions.length !== 0) {
logger.debug('Moving captions of %s.', video.uuid, lTags)
await moveCaptionFiles(captions)
}
const pendingMove = await VideoJobInfoModel.decrease(video.uuid, 'pendingMove')
if (pendingMove === 0) {
logger.info('Running cleanup after moving files (video %s in job %s)', video.uuid, jobId, lTags)
@ -69,7 +80,7 @@ export async function moveToJob (options: {
await doAfterLastMove(video)
}
} catch (err) {
await onMoveToStorageFailure({ videoUUID, err, lTags, moveToFailedState })
await onMoveVideoToStorageFailure({ videoUUID, err, lTags, moveToFailedState })
throw err
} finally {
@ -77,7 +88,7 @@ export async function moveToJob (options: {
}
}
export async function onMoveToStorageFailure (options: {
export async function onMoveVideoToStorageFailure (options: {
videoUUID: string
err: any
lTags: LoggerTags

View file

@ -10,7 +10,7 @@ import { MVideoFullLight } from '@server/types/models/index.js'
import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
import { logger } from '../../../helpers/logger.js'
import { JobQueue } from '../job-queue.js'
import { buildMoveJob } from '@server/lib/video-jobs.js'
import { buildMoveVideoJob } from '@server/lib/video-jobs.js'
import { buildNewFile } from '@server/lib/video-file.js'
async function processVideoFileImport (job: Job) {
@ -27,7 +27,7 @@ async function processVideoFileImport (job: Job) {
await updateVideoFile(video, payload.filePath)
if (CONFIG.OBJECT_STORAGE.ENABLED) {
await JobQueue.Instance.createJob(await buildMoveJob({ video, previousVideoState: video.state, type: 'move-to-object-storage' }))
await JobQueue.Instance.createJob(await buildMoveVideoJob({ video, previousVideoState: video.state, type: 'move-to-object-storage' }))
} else {
await federateVideoIfNeeded(video, false)
}

View file

@ -27,7 +27,7 @@ import { isUserQuotaValid } from '@server/lib/user.js'
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
import { buildNewFile } from '@server/lib/video-file.js'
import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { buildMoveVideoJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
@ -313,7 +313,7 @@ async function afterImportSuccess (options: {
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
await JobQueue.Instance.createJob(
await buildMoveJob({ video, previousVideoState: VideoState.TO_IMPORT, type: 'move-to-object-storage' })
await buildMoveVideoJob({ video, previousVideoState: VideoState.TO_IMPORT, type: 'move-to-object-storage' })
)
return
}

View file

@ -17,6 +17,10 @@ export function generateOriginalVideoObjectStorageKey (filename: string) {
return filename
}
export function generateCaptionObjectStorageKey (filename: string) {
return filename
}
export function generateUserExportObjectStorageKey (filename: string) {
return filename
}

View file

@ -50,14 +50,16 @@ async function storeObject (options: {
objectStorageKey: string
bucketInfo: BucketInfo
isPrivate: boolean
contentType?: string
}): Promise<string> {
const { inputPath, objectStorageKey, bucketInfo, isPrivate } = options
const { inputPath, objectStorageKey, bucketInfo, isPrivate, contentType } = options
logger.debug('Uploading file %s to %s%s in bucket %s', inputPath, bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags())
const fileStream = createReadStream(inputPath)
return uploadToStorage({ objectStorageKey, content: fileStream, bucketInfo, isPrivate })
return uploadToStorage({ objectStorageKey, content: fileStream, bucketInfo, isPrivate, contentType })
}
async function storeContent (options: {
@ -65,12 +67,14 @@ async function storeContent (options: {
objectStorageKey: string
bucketInfo: BucketInfo
isPrivate: boolean
contentType?: string
}): Promise<string> {
const { content, objectStorageKey, bucketInfo, isPrivate } = options
const { content, objectStorageKey, bucketInfo, isPrivate, contentType } = options
logger.debug('Uploading %s content to %s%s in bucket %s', content, bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags())
return uploadToStorage({ objectStorageKey, content, bucketInfo, isPrivate })
return uploadToStorage({ objectStorageKey, content, bucketInfo, isPrivate, contentType })
}
async function storeStream (options: {
@ -78,12 +82,14 @@ async function storeStream (options: {
objectStorageKey: string
bucketInfo: BucketInfo
isPrivate: boolean
contentType?: string
}): Promise<string> {
const { stream, objectStorageKey, bucketInfo, isPrivate } = options
const { stream, objectStorageKey, bucketInfo, isPrivate, contentType } = options
logger.debug('Streaming file to %s%s in bucket %s', bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags())
return uploadToStorage({ objectStorageKey, content: stream, bucketInfo, isPrivate })
return uploadToStorage({ objectStorageKey, content: stream, bucketInfo, isPrivate, contentType })
}
// ---------------------------------------------------------------------------
@ -296,13 +302,16 @@ async function uploadToStorage (options: {
objectStorageKey: string
bucketInfo: BucketInfo
isPrivate: boolean
contentType?: string
}) {
const { content, objectStorageKey, bucketInfo, isPrivate } = options
const { content, objectStorageKey, bucketInfo, isPrivate, contentType } = options
const input: PutObjectCommandInput = {
Body: content,
Bucket: bucketInfo.BUCKET_NAME,
Key: buildKey(objectStorageKey, bucketInfo)
Key: buildKey(objectStorageKey, bucketInfo),
ContentType: contentType
}
const acl = getACL(isPrivate)

View file

@ -1,11 +1,12 @@
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models/index.js'
import { MStreamingPlaylistVideo, MVideo, MVideoCaption, MVideoFile } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { basename, join } from 'path'
import { getHLSDirectory } from '../paths.js'
import { VideoPathManager } from '../video-path-manager.js'
import {
generateCaptionObjectStorageKey,
generateHLSObjectBaseStorageKey,
generateHLSObjectStorageKey,
generateOriginalVideoObjectStorageKey,
@ -71,6 +72,18 @@ export function storeWebVideoFile (video: MVideo, file: MVideoFile) {
// ---------------------------------------------------------------------------
export function storeVideoCaption (inputPath: string, filename: string) {
return storeObject({
inputPath,
objectStorageKey: generateCaptionObjectStorageKey(filename),
bucketInfo: CONFIG.OBJECT_STORAGE.CAPTIONS,
isPrivate: false,
contentType: 'text/vtt'
})
}
// ---------------------------------------------------------------------------
export function storeOriginalVideoFile (inputPath: string, filename: string) {
return storeObject({
inputPath,
@ -130,6 +143,12 @@ export function removeOriginalFileObjectStorage (videoSource: MVideoSource) {
// ---------------------------------------------------------------------------
export function removeCaptionObjectStorage (videoCaption: MVideoCaption) {
return removeObject(generateCaptionObjectStorageKey(videoCaption.filename), CONFIG.OBJECT_STORAGE.CAPTIONS)
}
// ---------------------------------------------------------------------------
export async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename: string, destination: string) {
const key = generateHLSObjectStorageKey(playlist, filename)
@ -172,6 +191,20 @@ export async function makeOriginalFileAvailable (keptOriginalFilename: string, d
return destination
}
export async function makeCaptionFileAvailable (filename: string, destination: string) {
const key = generateCaptionObjectStorageKey(filename)
logger.info('Fetching Caption file %s from object storage to %s.', key, destination, lTags())
await makeAvailable({
key,
destination,
bucketInfo: CONFIG.OBJECT_STORAGE.CAPTIONS
})
return destination
}
// ---------------------------------------------------------------------------
export function getWebVideoFileReadStream (options: {

View file

@ -1,4 +1,4 @@
import { VideoFileStream } from '@peertube/peertube-models'
import { FileStorage, VideoFileStream } from '@peertube/peertube-models'
import { buildSUUID } from '@peertube/peertube-node-utils'
import { AbstractTranscriber, TranscriptionModel, WhisperBuiltinModel, transcriberFactory } from '@peertube/peertube-transcription'
import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js'
@ -34,6 +34,7 @@ export async function createLocalCaption (options: {
const videoCaption = new VideoCaptionModel({
videoId: video.id,
filename: VideoCaptionModel.generateCaptionName(language),
storage: FileStorage.FILE_SYSTEM,
language,
automaticallyGenerated
}) as MVideoCaption
@ -46,6 +47,12 @@ export async function createLocalCaption (options: {
})
})
if (CONFIG.OBJECT_STORAGE.ENABLED) {
await JobQueue.Instance.createJob({ type: 'move-to-object-storage', payload: { captionId: videoCaption.id } })
}
logger.info(`Created/replaced caption ${videoCaption.filename} of ${language} of video ${video.uuid}`, lTags(video.uuid))
return Object.assign(videoCaption, { Video: video })
}

View file

@ -6,7 +6,7 @@ import { CreateJobArgument, CreateJobOptions, JobQueue } from './job-queue/job-q
import { createTranscriptionTaskIfNeeded } from './video-captions.js'
import { moveFilesIfPrivacyChanged } from './video-privacy.js'
export async function buildMoveJob (options: {
export async function buildMoveVideoJob (options: {
video: MVideoUUID
previousVideoState: VideoStateType
type: 'move-to-object-storage' | 'move-to-file-system'
@ -92,7 +92,7 @@ export async function addVideoJobsAfterCreation (options: {
]
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
jobs.push(await buildMoveJob({ video, previousVideoState: undefined, type: 'move-to-object-storage' }))
jobs.push(await buildMoveVideoJob({ video, previousVideoState: undefined, type: 'move-to-object-storage' }))
}
if (video.state === VideoState.TO_TRANSCODE) {

View file

@ -10,7 +10,7 @@ import { MVideo, MVideoFullLight, MVideoUUID } from '@server/types/models/index.
import { federateVideoIfNeeded } from './activitypub/videos/index.js'
import { JobQueue } from './job-queue/index.js'
import { Notifier } from './notifier/index.js'
import { buildMoveJob } from './video-jobs.js'
import { buildMoveVideoJob } from './video-jobs.js'
function buildNextVideoState (currentState?: VideoStateType) {
if (currentState === VideoState.PUBLISHED) {
@ -94,7 +94,7 @@ async function moveToExternalStorageState (options: {
logger.info('Creating external storage move job for video %s.', video.uuid, { tags: [ video.uuid ] })
try {
await JobQueue.Instance.createJob(await buildMoveJob({ video, previousVideoState, isNewVideo, type: 'move-to-object-storage' }))
await JobQueue.Instance.createJob(await buildMoveVideoJob({ video, previousVideoState, isNewVideo, type: 'move-to-object-storage' }))
return true
} catch (err) {
@ -120,7 +120,7 @@ async function moveToFileSystemState (options: {
logger.info('Creating move to file system job for video %s.', video.uuid, { tags: [ video.uuid ] })
try {
await JobQueue.Instance.createJob(await buildMoveJob({ video, previousVideoState, isNewVideo, type: 'move-to-file-system' }))
await JobQueue.Instance.createJob(await buildMoveVideoJob({ video, previousVideoState, isNewVideo, type: 'move-to-file-system' }))
return true
} catch (err) {

View file

@ -1,6 +1,6 @@
import { ActivityIconObject, ActorImage, ActorImageType, type ActorImageType_Type } from '@peertube/peertube-models'
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
import { MActorId, MActorImage, MActorImageFormattable } from '@server/types/models/index.js'
import { MActorId, MActorImage, MActorImageFormattable, MActorImagePath } from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { Op } from 'sequelize'
@ -149,7 +149,7 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
})
}
static getImageUrl (image: MActorImage) {
static getImageUrl (image: MActorImagePath) {
if (!image) return undefined
return WEBSERVER.URL + image.getStaticPath()
@ -161,6 +161,7 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
return {
width: this.width,
path: this.getStaticPath(),
fileUrl: ActorImageModel.getImageUrl(this),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
@ -178,7 +179,7 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
}
}
getStaticPath () {
getStaticPath (this: MActorImagePath) {
switch (this.type) {
case ActorImageType.AVATAR:
return join(LAZY_STATIC_PATHS.AVATARS, this.filename)

View file

@ -21,6 +21,7 @@ import { VideoModel } from '../video/video.js'
import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder.js'
import { UserRegistrationModel } from './user-registration.js'
import { UserModel } from './user.js'
import { ActorImageModel } from '../actor/actor-image.js'
@Table({
tableName: 'userNotification',
@ -552,13 +553,7 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
return {
path: a.getStaticPath(),
width: a.width
}
}
formatVideoCaption (a: UserNotificationIncludes.ActorImageInclude) {
return {
fileUrl: ActorImageModel.getImageUrl(a),
path: a.getStaticPath(),
width: a.width
}

View file

@ -141,6 +141,10 @@ export class StoryboardModel extends SequelizeModel<StoryboardModel> {
return this.fileUrl
}
getFileUrl () {
return WEBSERVER.URL + this.getLocalStaticPath()
}
getLocalStaticPath () {
return LAZY_STATIC_PATHS.STORYBOARDS + this.filename
}
@ -155,6 +159,7 @@ export class StoryboardModel extends SequelizeModel<StoryboardModel> {
toFormattedJSON (this: MStoryboardVideo): Storyboard {
return {
fileUrl: this.getFileUrl(),
storyboardPath: this.getLocalStaticPath(),
totalHeight: this.totalHeight,

View file

@ -191,7 +191,8 @@ export class ThumbnailModel extends SequelizeModel<ThumbnailModel> {
getOriginFileUrl (videoOrPlaylist: MVideo | MVideoPlaylist) {
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
if (videoOrPlaylist.isOwned()) return WEBSERVER.URL + staticPath
// FIXME: typings
if ((videoOrPlaylist as MVideo).isOwned()) return WEBSERVER.URL + staticPath
return this.fileUrl
}

View file

@ -1,11 +1,14 @@
import { VideoCaption, VideoCaptionObject } from '@peertube/peertube-models'
import { FileStorage, type FileStorageType, VideoCaption, VideoCaptionObject } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { getObjectStoragePublicFileUrl } from '@server/lib/object-storage/urls.js'
import { removeCaptionObjectStorage } from '@server/lib/object-storage/videos.js'
import {
MVideo,
MVideoCaption,
MVideoCaptionFormattable,
MVideoCaptionLanguageUrl,
MVideoCaptionVideo
MVideoCaptionVideo,
MVideoOwned
} from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
@ -17,6 +20,7 @@ import {
Column,
CreatedAt,
DataType,
Default,
ForeignKey,
Is, Scopes,
Table,
@ -26,7 +30,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
import { logger } from '../../helpers/logger.js'
import { CONFIG } from '../../initializers/config.js'
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants.js'
import { SequelizeModel, buildWhereIdOrUUID, throwIfNotValid } from '../shared/index.js'
import { SequelizeModel, buildWhereIdOrUUID, doesExist, throwIfNotValid } from '../shared/index.js'
import { VideoModel } from './video.js'
export enum ScopeNames {
@ -79,6 +83,11 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
@Column
filename: string
@AllowNull(false)
@Default(FileStorage.FILE_SYSTEM)
@Column
storage: FileStorageType
@AllowNull(true)
@Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
fileUrl: string
@ -127,8 +136,30 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
return caption.save({ transaction })
}
static async doesOwnedFileExist (filename: string, storage: FileStorageType) {
const query = 'SELECT 1 FROM "videoCaption" ' +
`WHERE "filename" = $filename AND "storage" = $storage LIMIT 1`
return doesExist({ sequelize: this.sequelize, query, bind: { filename, storage } })
}
// ---------------------------------------------------------------------------
static loadWithVideo (captionId: number, transaction?: Transaction): Promise<MVideoCaptionVideo> {
const query = {
where: { id: captionId },
include: [
{
model: VideoModel.unscoped(),
attributes: videoAttributes
}
],
transaction
}
return VideoCaptionModel.findOne(query)
}
static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
const videoInclude = {
model: VideoModel.unscoped(),
@ -231,7 +262,13 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
label: VideoCaptionModel.getLanguageLabel(this.language)
},
automaticallyGenerated: this.automaticallyGenerated,
captionPath: this.getCaptionStaticPath(),
captionPath: this.Video.isOwned() && this.fileUrl
? null // On object storage
: this.getCaptionStaticPath(),
fileUrl: this.getFileUrl(this.Video),
updatedAt: this.updatedAt.toISOString()
}
}
@ -241,7 +278,7 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
identifier: this.language,
name: VideoCaptionModel.getLanguageLabel(this.language),
automaticallyGenerated: this.automaticallyGenerated,
url: this.getFileUrl(video)
url: this.getOriginFileUrl(video)
}
}
@ -260,15 +297,31 @@ export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
}
removeCaptionFile (this: MVideoCaption) {
if (this.storage === FileStorage.OBJECT_STORAGE) {
return removeCaptionObjectStorage(this)
}
return remove(this.getFSPath())
}
getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideo) {
if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
// ---------------------------------------------------------------------------
getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideoOwned) {
if (video.isOwned() && this.storage === FileStorage.OBJECT_STORAGE) {
return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.CAPTIONS)
}
return WEBSERVER.URL + this.getCaptionStaticPath()
}
getOriginFileUrl (this: MVideoCaptionLanguageUrl, video: MVideoOwned) {
if (video.isOwned()) return this.getFileUrl(video)
return this.fileUrl
}
// ---------------------------------------------------------------------------
isEqual (this: MVideoCaption, other: MVideoCaption) {
if (this.fileUrl) return this.fileUrl === other.fileUrl

View file

@ -278,7 +278,7 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
return doesExist({ sequelize: this.sequelize, query, bind: { filename } })
}
static async doesOwnedFileExist (filename: string, storage: FileStorageType) {
static async doesOwnedWebVideoFileExist (filename: string, storage: FileStorageType) {
const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
`WHERE "filename" = $filename AND "storage" = $storage LIMIT 1`

View file

@ -99,6 +99,7 @@ import {
MVideoFullLight,
MVideoId,
MVideoImmutable,
MVideoOwned,
MVideoThumbnail,
MVideoThumbnailBlacklist,
MVideoWithAllFiles,
@ -935,7 +936,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
},
include: [
{
attributes: [ 'filename', 'language', 'fileUrl' ],
attributes: [ 'filename', 'language', 'storage', 'fileUrl' ],
model: VideoCaptionModel.unscoped(),
required: false
},
@ -1845,7 +1846,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
// ---------------------------------------------------------------------------
isOwned () {
isOwned (this: MVideoOwned) {
return this.remote === false
}
@ -1922,7 +1923,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
return this.$get('VideoCaptions', {
attributes: [ 'filename', 'language', 'fileUrl', 'automaticallyGenerated' ],
attributes: [ 'filename', 'language', 'fileUrl', 'storage', 'automaticallyGenerated' ],
transaction
}) as Promise<MVideoCaptionLanguageUrl[]>
}

View file

@ -5,8 +5,10 @@ export type MActorImage = ActorImageModel
// ############################################################################
export type MActorImagePath = Pick<MActorImage, 'type' | 'filename' | 'getStaticPath'>
// Format for API or AP object
export type MActorImageFormattable =
FunctionProperties<MActorImage> &
Pick<MActorImage, 'width' | 'filename' | 'createdAt' | 'updatedAt'>
Pick<MActorImage, 'type' | 'getStaticPath' | 'width' | 'filename' | 'createdAt' | 'updatedAt'>

View file

@ -23,7 +23,7 @@ type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationMo
// ############################################################################
export module UserNotificationIncludes {
export type ActorImageInclude = Pick<ActorImageModel, 'createdAt' | 'filename' | 'getStaticPath' | 'width' | 'updatedAt'>
export type ActorImageInclude = Pick<ActorImageModel, 'createdAt' | 'filename' | 'type' | 'getStaticPath' | 'width' | 'updatedAt'>
export type VideoInclude = Pick<VideoModel, 'id' | 'uuid' | 'name' | 'state'>
export type VideoIncludeChannel =

View file

@ -1,6 +1,6 @@
import { PickWith } from '@peertube/peertube-typescript-utils'
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
import { MVideo, MVideoUUID } from './video.js'
import { MVideo, MVideoOwned, MVideoUUID } from './video.js'
type Use<K extends keyof VideoCaptionModel, M> = PickWith<VideoCaptionModel, K, M>
@ -12,12 +12,12 @@ export type MVideoCaption = Omit<VideoCaptionModel, 'Video'>
export type MVideoCaptionLanguage = Pick<MVideoCaption, 'language'>
export type MVideoCaptionLanguageUrl =
Pick<MVideoCaption, 'language' | 'fileUrl' | 'filename' | 'automaticallyGenerated' | 'getFileUrl' | 'getCaptionStaticPath' |
'toActivityPubObject'>
Pick<MVideoCaption, 'language' | 'fileUrl' | 'storage' | 'filename' | 'automaticallyGenerated' | 'getFileUrl' | 'getCaptionStaticPath' |
'toActivityPubObject' | 'getOriginFileUrl'>
export type MVideoCaptionVideo =
MVideoCaption &
Use<'Video', Pick<MVideo, 'id' | 'name' | 'remote' | 'uuid' | 'url' | 'state' | 'getWatchStaticPath'>>
Use<'Video', Pick<MVideo, 'id' | 'name' | 'remote' | 'uuid' | 'url' | 'state' | 'getWatchStaticPath' | 'isOwned'>>
// ############################################################################
@ -26,4 +26,4 @@ export type MVideoCaptionVideo =
export type MVideoCaptionFormattable =
MVideoCaption &
Pick<MVideoCaption, 'language'> &
Use<'Video', MVideoUUID>
Use<'Video', MVideoOwned & MVideoUUID>

View file

@ -44,6 +44,7 @@ export type MVideoUrl = Pick<MVideo, 'url'>
export type MVideoUUID = Pick<MVideo, 'uuid'>
export type MVideoImmutable = Pick<MVideo, 'id' | 'url' | 'uuid' | 'remote' | 'isOwned'>
export type MVideoOwned = Pick<MVideo, 'remote' | 'isOwned'>
export type MVideoIdUrl = MVideoId & MVideoUrl
export type MVideoFeed = Pick<MVideo, 'name' | 'uuid'>

View file

@ -1,12 +1,15 @@
import { program } from 'commander'
import { FileStorage, VideoState } from '@peertube/peertube-models'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { initDatabaseModels } from '@server/initializers/database.js'
import { JobQueue } from '@server/lib/job-queue/index.js'
import { moveToExternalStorageState, moveToFileSystemState } from '@server/lib/video-state.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoModel } from '@server/models/video/video.js'
import { VideoState, FileStorage } from '@peertube/peertube-models'
import { MStreamingPlaylist, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MStreamingPlaylist, MVideoCaption, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { program } from 'commander'
program
.description('Move videos to another storage.')
@ -83,8 +86,13 @@ async function run () {
await createMoveJobIfNeeded({
video: videoFull,
type: 'to object storage',
canProcessVideo: (files, hls) => {
return files.some(f => f.storage === FileStorage.FILE_SYSTEM) || hls?.storage === FileStorage.FILE_SYSTEM
canProcessVideo: (options) => {
const { files, hls, source, captions } = options
return files.some(f => f.storage === FileStorage.FILE_SYSTEM) ||
hls?.storage === FileStorage.FILE_SYSTEM ||
source?.storage === FileStorage.FILE_SYSTEM ||
captions.some(c => c.storage === FileStorage.FILE_SYSTEM)
},
handler: () => moveToExternalStorageState({ video: videoFull, isNewVideo: false, transaction: undefined })
})
@ -97,9 +105,15 @@ async function run () {
video: videoFull,
type: 'to file system',
canProcessVideo: (files, hls) => {
return files.some(f => f.storage === FileStorage.OBJECT_STORAGE) || hls?.storage === FileStorage.OBJECT_STORAGE
canProcessVideo: options => {
const { files, hls, source, captions } = options
return files.some(f => f.storage === FileStorage.OBJECT_STORAGE) ||
hls?.storage === FileStorage.OBJECT_STORAGE ||
source?.storage === FileStorage.OBJECT_STORAGE ||
captions.some(c => c.storage === FileStorage.OBJECT_STORAGE)
},
handler: () => moveToFileSystemState({ video: videoFull, isNewVideo: false, transaction: undefined })
})
}
@ -110,7 +124,13 @@ async function createMoveJobIfNeeded (options: {
video: MVideoFullLight
type: 'to object storage' | 'to file system'
canProcessVideo: (files: MVideoFile[], hls: MStreamingPlaylist) => boolean
canProcessVideo: (options: {
files: MVideoFile[]
hls: MStreamingPlaylist
source: MVideoSource
captions: MVideoCaption[]
}) => boolean
handler: () => Promise<any>
}) {
const { video, type, canProcessVideo, handler } = options
@ -118,7 +138,10 @@ async function createMoveJobIfNeeded (options: {
const files = video.VideoFiles || []
const hls = video.getHLSPlaylist()
if (canProcessVideo(files, hls)) {
const source = await VideoSourceModel.loadLatest(video.id)
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
if (canProcessVideo({ files, hls, source, captions })) {
console.log(`Moving ${type} video ${video.name}`)
const success = await handler()

View file

@ -6,7 +6,7 @@ import Bluebird from 'bluebird'
import { askConfirmation, displayPeerTubeMustBeStoppedWarning } from './shared/common.js'
const program = createCommand()
.description('Remove unused objects from database or remote files')
.description('Remove remote files')
.option('--delete-remote-files', 'Remove remote files (avatars, banners, thumbnails...)')
.parse(process.argv)

View file

@ -1,3 +1,4 @@
import { createCommand } from '@commander-js/extra-typings'
import { uniqify } from '@peertube/peertube-core-utils'
import { FileStorage, ThumbnailType, ThumbnailType_Type } from '@peertube/peertube-models'
import { DIRECTORIES, USER_EXPORT_FILE_PREFIX } from '@server/initializers/constants.js'
@ -21,6 +22,13 @@ import { ThumbnailModel } from '../core/models/video/thumbnail.js'
import { VideoModel } from '../core/models/video/video.js'
import { askConfirmation, displayPeerTubeMustBeStoppedWarning } from './shared/common.js'
const program = createCommand()
.description('Remove unused local objects (video files, captions, user exports...) from object storage or file system')
.option('-y, --yes', 'Auto confirm files deletion')
.parse(process.argv)
const options = program.opts()
run()
.then(() => process.exit(0))
.catch(err => {
@ -56,6 +64,7 @@ class ObjectStoragePruner {
await this.findFilesToDelete(CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS, this.doesStreamingPlaylistFileExistFactory())
await this.findFilesToDelete(CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES, this.doesOriginalFileExistFactory())
await this.findFilesToDelete(CONFIG.OBJECT_STORAGE.USER_EXPORTS, this.doesUserExportFileExistFactory())
await this.findFilesToDelete(CONFIG.OBJECT_STORAGE.CAPTIONS, this.doesCaptionFileExistFactory())
if (this.keysToDelete.length === 0) {
console.log('No unknown object storage files to delete.')
@ -65,7 +74,7 @@ class ObjectStoragePruner {
const formattedKeysToDelete = this.keysToDelete.map(({ bucket, key }) => ` In bucket ${bucket}: ${key}`).join('\n')
console.log(`${this.keysToDelete.length} unknown files from object storage can be deleted:\n${formattedKeysToDelete}\n`)
const res = await askPruneConfirmation()
const res = await askPruneConfirmation(options.yes)
if (res !== true) {
console.log('Exiting without deleting object storage files.')
return
@ -97,7 +106,7 @@ class ObjectStoragePruner {
? ` and prefix ${config.PREFIX}`
: ''
console.error('Cannot find files to delete in bucket ' + config.BUCKET_NAME + prefixMessage)
console.error('Cannot find files to delete in bucket ' + config.BUCKET_NAME + prefixMessage, { err })
}
}
@ -105,13 +114,14 @@ class ObjectStoragePruner {
return (key: string) => {
const filename = this.sanitizeKey(key, CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
return VideoFileModel.doesOwnedFileExist(filename, FileStorage.OBJECT_STORAGE)
return VideoFileModel.doesOwnedWebVideoFileExist(filename, FileStorage.OBJECT_STORAGE)
}
}
private doesStreamingPlaylistFileExistFactory () {
return (key: string) => {
const uuid = basename(dirname(this.sanitizeKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)))
const sanitizedKey = this.sanitizeKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
const uuid = dirname(sanitizedKey).replace(/^hls\//, '')
return VideoStreamingPlaylistModel.doesOwnedVideoUUIDExist(uuid, FileStorage.OBJECT_STORAGE)
}
@ -133,6 +143,14 @@ class ObjectStoragePruner {
}
}
private doesCaptionFileExistFactory () {
return (key: string) => {
const filename = this.sanitizeKey(key, CONFIG.OBJECT_STORAGE.CAPTIONS)
return VideoCaptionModel.doesOwnedFileExist(filename, FileStorage.OBJECT_STORAGE)
}
}
private sanitizeKey (key: string, config: { PREFIX: string }) {
return key.replace(new RegExp(`^${config.PREFIX}`), '')
}
@ -191,7 +209,7 @@ class FSPruner {
const formattedKeysToDelete = this.pathsToDelete.map(p => ` ${p}`).join('\n')
console.log(`${this.pathsToDelete.length} unknown files from filesystem can be deleted:\n${formattedKeysToDelete}\n`)
const res = await askPruneConfirmation()
const res = await askPruneConfirmation(options.yes)
if (res !== true) {
console.log('Exiting without deleting filesystem files.')
return
@ -223,7 +241,7 @@ class FSPruner {
// Don't delete private directory
if (filePath === DIRECTORIES.WEB_VIDEOS.PRIVATE) return true
return VideoFileModel.doesOwnedFileExist(basename(filePath), FileStorage.FILE_SYSTEM)
return VideoFileModel.doesOwnedWebVideoFileExist(basename(filePath), FileStorage.FILE_SYSTEM)
}
}
@ -320,7 +338,9 @@ class FSPruner {
}
}
async function askPruneConfirmation () {
async function askPruneConfirmation (yes?: boolean) {
if (yes === true) return true
return askConfirmation(
'These unknown files can be deleted, but please check your backups first (bugs happen). ' +
'Can we delete these files?'

View file

@ -38,7 +38,8 @@ async function run () {
`SELECT COUNT(*) AS "c", 'videoStreamingPlaylist->playlistUrl: ' || COUNT(*) AS "t" FROM "videoStreamingPlaylist" WHERE "playlistUrl" ~ :fromRegexp AND "storage" = :storage`,
`SELECT COUNT(*) AS "c", 'videoStreamingPlaylist->segmentsSha256Url: ' || COUNT(*) AS "t" FROM "videoStreamingPlaylist" WHERE "segmentsSha256Url" ~ :fromRegexp AND "storage" = :storage`,
`SELECT COUNT(*) AS "c", 'userExport->fileUrl: ' || COUNT(*) AS "t" FROM "userExport" WHERE "fileUrl" ~ :fromRegexp AND "storage" = :storage`,
`SELECT COUNT(*) AS "c", 'videoSource->fileUrl: ' || COUNT(*) AS "t" FROM "videoSource" WHERE "fileUrl" ~ :fromRegexp AND "storage" = :storage`
`SELECT COUNT(*) AS "c", 'videoSource->fileUrl: ' || COUNT(*) AS "t" FROM "videoSource" WHERE "fileUrl" ~ :fromRegexp AND "storage" = :storage`,
`SELECT COUNT(*) AS "c", 'videoCaption->fileUrl: ' || COUNT(*) AS "t" FROM "videoCaption" WHERE "fileUrl" ~ :fromRegexp AND "storage" = :storage`
]
let hasResults = false
@ -73,7 +74,8 @@ async function run () {
`UPDATE "videoStreamingPlaylist" SET "playlistUrl" = regexp_replace("playlistUrl", :fromRegexp, :to) WHERE "storage" = :storage`,
`UPDATE "videoStreamingPlaylist" SET "segmentsSha256Url" = regexp_replace("segmentsSha256Url", :fromRegexp, :to) WHERE "storage" = :storage`,
`UPDATE "userExport" SET "fileUrl" = regexp_replace("fileUrl", :fromRegexp, :to) WHERE "storage" = :storage`,
`UPDATE "videoSource" SET "fileUrl" = regexp_replace("fileUrl", :fromRegexp, :to) WHERE "storage" = :storage`
`UPDATE "videoSource" SET "fileUrl" = regexp_replace("fileUrl", :fromRegexp, :to) WHERE "storage" = :storage`,
`UPDATE "videoCaption" SET "fileUrl" = regexp_replace("fileUrl", :fromRegexp, :to) WHERE "storage" = :storage`
]
for (const query of queries) {

View file

@ -112,6 +112,11 @@ object_storage:
prefix: "PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_PREFIX"
base_url: "PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_BASE_URL"
captions:
bucket_name: "PEERTUBE_OBJECT_STORAGE_CAPTIONS_BUCKET_NAME"
prefix: "PEERTUBE_OBJECT_STORAGE_CAPTIONS_PREFIX"
base_url: "PEERTUBE_OBJECT_STORAGE_CAPTIONS_BASE_URL"
webadmin:
configuration:
edition: