1
0
Fork 0

Add script to move videos to file system

This commit is contained in:
Chocobozzz 2023-10-31 12:15:40 +01:00
parent 443358ccce
commit d3c9a2e5b9
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
24 changed files with 545 additions and 237 deletions

View File

@ -36,7 +36,7 @@ export class LoginPage {
}
if (this.isMobileDevice) {
const menuToggle = $('.top-left-block span[role=button]')
const menuToggle = $('.top-left-block button')
await $('h2=Our content selection').waitForDisplayed()

View File

@ -40,7 +40,7 @@ export class PlayerPage {
await browser.waitUntil(async () => {
return (await this.getWatchVideoPlayerCurrentTime()) >= waitUntilSec
}, { timeout: waitUntilSec * 2 * 1000 })
}, { timeout: Math.max(waitUntilSec * 2 * 1000, 30000) })
// Pause video
await $('div.video-js').click()

View File

@ -37,6 +37,7 @@ export class JobsComponent extends RestTable implements OnInit {
'federate-video',
'manage-video-torrent',
'move-to-object-storage',
'move-to-file-system',
'notify',
'video-channel-import',
'video-file-import',

View File

@ -1,27 +1,3 @@
<div i18n class="alert alert-warning" *ngIf="isVideoTranscodingFailed()">
Transcoding failed, this video may not work properly.
</div>
<div i18n class="alert alert-warning" *ngIf="isVideoMoveToObjectStorageFailed()">
Move to external storage failed, this video may not work properly.
</div>
<div i18n class="alert alert-warning" *ngIf="isVideoToImport()">
The video is being imported, it will be available when the import is finished.
</div>
<div i18n class="alert alert-warning" *ngIf="isVideoToTranscode()">
The video is being transcoded, it may not work properly.
</div>
<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
The video is being edited, it may not work properly.
</div>
<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
The video is being moved to an external server, it may not work properly.
</div>
<div i18n class="alert pt-alert-primary" *ngIf="hasVideoScheduledPublication()">
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
</div>
@ -34,6 +10,10 @@
This live has ended.
</div>
<div class="alert alert-warning" *ngIf="getAlertWarning()">
{{ getAlertWarning() }}
</div>
<div i18n class="alert alert-warning" *ngIf="noPlaylistVideoFound">
There are no videos available in this playlist.
</div>

View File

@ -13,28 +13,34 @@ export class VideoAlertComponent {
@Input() video: VideoDetails
@Input() noPlaylistVideoFound: boolean
isVideoToTranscode () {
return this.video && this.video.state.id === VideoState.TO_TRANSCODE
}
getAlertWarning () {
if (!this.video) return
isVideoToEdit () {
return this.video && this.video.state.id === VideoState.TO_EDIT
}
switch (this.video.state.id) {
case VideoState.TO_TRANSCODE:
return $localize`The video is being transcoded, it may not work properly.`
isVideoTranscodingFailed () {
return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
}
case VideoState.TO_IMPORT:
return $localize`The video is being imported, it will be available when the import is finished.`
isVideoMoveToObjectStorageFailed () {
return this.video && this.video.state.id === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED
}
case VideoState.TO_MOVE_TO_FILE_SYSTEM:
return $localize`The video is being moved to server file system, it may not work properly`
isVideoToImport () {
return this.video && this.video.state.id === VideoState.TO_IMPORT
}
case VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED:
return $localize`Move to file system failed, this video may not work properly.`
isVideoToMoveToExternalStorage () {
return this.video && this.video.state.id === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE
case VideoState.TO_MOVE_TO_EXTERNAL_STORAGE:
return $localize`The video is being moved to an external server, it may not work properly.`
case VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED:
return $localize`Move to external storage failed, this video may not work properly.`
case VideoState.TO_EDIT:
return $localize`The video is being edited, it may not work properly.`
case VideoState.TRANSCODING_FAILED:
return $localize`Transcoding failed, this video may not work properly.`
}
}
hasVideoScheduledPublication () {

View File

@ -187,28 +187,32 @@ export class VideoMiniatureComponent implements OnInit {
return $localize`Publication scheduled on ${updateAt}`
}
if (video.state.id === VideoState.TRANSCODING_FAILED) {
return $localize`Transcoding failed`
}
switch (video.state.id) {
case VideoState.TRANSCODING_FAILED:
return $localize`Transcoding failed`
if (video.state.id === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED) {
return $localize`Move to external storage failed`
}
case VideoState.TO_MOVE_TO_FILE_SYSTEM:
return $localize`Moving to file system`
if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
return $localize`Waiting transcoding`
}
case VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED:
return $localize`Moving to file system failed`
if (video.state.id === VideoState.TO_TRANSCODE) {
return $localize`To transcode`
}
case VideoState.TO_MOVE_TO_EXTERNAL_STORAGE:
return $localize`Moving to external storage`
if (video.state.id === VideoState.TO_IMPORT) {
return $localize`To import`
}
case VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED:
return $localize`Move to external storage failed`
if (video.state.id === VideoState.TO_EDIT) {
return $localize`To edit`
case VideoState.TO_TRANSCODE:
return video.waitTranscoding === true
? $localize`Waiting transcoding`
: $localize`To transcode`
case VideoState.TO_IMPORT:
return $localize`To import`
case VideoState.TO_EDIT:
return $localize`To edit`
}
return ''

View File

@ -20,6 +20,7 @@ export type JobType =
| 'transcoding-job-builder'
| 'manage-video-torrent'
| 'move-to-object-storage'
| 'move-to-file-system'
| 'notify'
| 'video-channel-import'
| 'video-file-import'
@ -196,7 +197,7 @@ export interface DeleteResumableUploadMetaFilePayload {
filepath: string
}
export interface MoveObjectStoragePayload {
export interface MoveStoragePayload {
videoUUID: string
isNewVideo: boolean
previousVideoState: VideoStateType

View File

@ -7,7 +7,9 @@ export const VideoState = {
TO_MOVE_TO_EXTERNAL_STORAGE: 6,
TRANSCODING_FAILED: 7,
TO_MOVE_TO_EXTERNAL_STORAGE_FAILED: 8,
TO_EDIT: 9
TO_EDIT: 9,
TO_MOVE_TO_FILE_SYSTEM: 10,
TO_MOVE_TO_FILE_SYSTEM_FAILED: 11
} as const
export type VideoStateType = typeof VideoState[keyof typeof VideoState]

View File

@ -15,6 +15,7 @@ import {
} from '@peertube/peertube-server-commands'
import { expectStartWith } from '../shared/checks.js'
import { checkDirectoryIsEmpty } from '@tests/shared/directories.js'
import { getAllFiles } from '@peertube/peertube-core-utils'
async function checkFiles (origin: PeerTubeServer, video: VideoDetails, objectStorage?: ObjectStorageCommand) {
for (const file of video.files) {
@ -73,48 +74,106 @@ describe('Test create move video storage job', function () {
await servers[0].run(objectStorage.getDefaultMockConfig())
})
it('Should move only one file', async function () {
this.timeout(120000)
describe('To object storage', function () {
const command = `npm run create-move-video-storage-job -- --to-object-storage -v ${uuids[1]}`
await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
await waitJobs(servers)
it('Should move only one file', async function () {
this.timeout(120000)
for (const server of servers) {
const video = await server.videos.get({ id: uuids[1] })
const command = `npm run create-move-video-storage-job -- --to-object-storage -v ${uuids[1]}`
await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
await waitJobs(servers)
await checkFiles(servers[0], video, objectStorage)
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video)
}
}
})
it('Should move all files', async function () {
this.timeout(120000)
const command = `npm run create-move-video-storage-job -- --to-object-storage --all-videos`
await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
await waitJobs(servers)
for (const server of servers) {
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
for (const server of servers) {
const video = await server.videos.get({ id: uuids[1] })
await checkFiles(servers[0], video, objectStorage)
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video)
}
}
}
})
it('Should move all files', async function () {
this.timeout(120000)
const command = `npm run create-move-video-storage-job -- --to-object-storage --all-videos`
await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
await waitJobs(servers)
for (const server of servers) {
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video, objectStorage)
}
}
})
it('Should not have files on disk anymore', async function () {
await checkDirectoryIsEmpty(servers[0], 'web-videos', [ 'private' ])
await checkDirectoryIsEmpty(servers[0], join('web-videos', 'private'))
await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls'), [ 'private' ])
await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls', 'private'))
})
})
it('Should not have files on disk anymore', async function () {
await checkDirectoryIsEmpty(servers[0], 'web-videos', [ 'private' ])
await checkDirectoryIsEmpty(servers[0], join('web-videos', 'private'))
describe('To file system', function () {
let oldFileUrls: string[]
await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls'), [ 'private' ])
await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls', 'private'))
before(async function () {
const video = await servers[0].videos.get({ id: uuids[1] })
oldFileUrls = [
...getAllFiles(video).map(f => f.fileUrl),
video.streamingPlaylists[0].playlistUrl
]
})
it('Should move only one file', async function () {
this.timeout(120000)
const command = `npm run create-move-video-storage-job -- --to-file-system -v ${uuids[1]}`
await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
await waitJobs(servers)
for (const server of servers) {
const video = await server.videos.get({ id: uuids[1] })
await checkFiles(servers[0], video)
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video, objectStorage)
}
}
})
it('Should move all files', async function () {
this.timeout(120000)
const command = `npm run create-move-video-storage-job -- --to-file-system --all-videos`
await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
await waitJobs(servers)
for (const server of servers) {
for (const id of [ uuids[0], uuids[2] ]) {
const video = await server.videos.get({ id })
await checkFiles(servers[0], video)
}
}
})
it('Should not have files on disk anymore', async function () {
for (const fileUrl of oldFileUrls) {
await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}
})
})
after(async function () {

View File

@ -5,7 +5,7 @@ import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-q
import { Hooks } from '@server/lib/plugins/hooks.js'
import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
import { uploadx } from '@server/lib/uploadx.js'
import { buildMoveToObjectStorageJob } from '@server/lib/video.js'
import { buildMoveJob } from '@server/lib/video.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { buildNewFile } from '@server/lib/video-file.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
@ -171,7 +171,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
]
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
jobs.push(await buildMoveToObjectStorageJob({ video, isNewVideo: false, previousVideoState: undefined }))
jobs.push(await buildMoveJob({ video, isNewVideo: false, previousVideoState: undefined, type: 'move-to-object-storage' }))
}
if (video.state === VideoState.TO_TRANSCODE) {

View File

@ -6,7 +6,7 @@ import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Redis } from '@server/lib/redis.js'
import { uploadx } from '@server/lib/uploadx.js'
import { buildLocalVideoFromReq, buildMoveToObjectStorageJob, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video.js'
import { buildLocalVideoFromReq, buildMoveJob, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video.js'
import { buildNewFile } from '@server/lib/video-file.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
@ -275,7 +275,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
]
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
jobs.push(await buildMoveToObjectStorageJob({ video, previousVideoState: undefined }))
jobs.push(await buildMoveJob({ video, previousVideoState: undefined, type: 'move-to-object-storage' }))
}
if (video.state === VideoState.TO_TRANSCODE) {

View File

@ -121,7 +121,8 @@ const bunyanLogger = {
// ---------------------------------------------------------------------------
type LoggerTagsFn = (...tags: string[]) => { tags: string[] }
type LoggerTags = { tags: string[] }
type LoggerTagsFn = (...tags: string[]) => LoggerTags
function loggerTagsFactory (...defaultTags: string[]): LoggerTagsFn {
return (...tags: string[]) => {
return { tags: defaultTags.concat(tags) }
@ -154,6 +155,7 @@ async function mtimeSortFilesDesc (files: string[], basePath: string) {
export {
type LoggerTagsFn,
type LoggerTags,
buildLogger,
timestampFormatter,

View File

@ -186,6 +186,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
'video-channel-import': 1,
'after-video-channel-import': 1,
'move-to-object-storage': 3,
'move-to-file-system': 3,
'transcoding-job-builder': 1,
'generate-video-storyboard': 1,
'notify': 1,
@ -209,6 +210,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
'video-studio-edition': 1,
'manage-video-torrent': 1,
'move-to-object-storage': 1,
'move-to-file-system': 1,
'video-channel-import': 1,
'after-video-channel-import': 1,
'transcoding-job-builder': 1,
@ -236,6 +238,7 @@ const JOB_TTL: { [id in JobType]: number } = {
'generate-video-storyboard': 1000 * 60 * 10, // 10 minutes
'manage-video-torrent': 1000 * 3600 * 3, // 3 hours
'move-to-object-storage': 1000 * 60 * 60 * 3, // 3 hours
'move-to-file-system': 1000 * 60 * 60 * 3, // 3 hours
'video-channel-import': 1000 * 60 * 60 * 4, // 4 hours
'after-video-channel-import': 60000 * 5, // 5 minutes
'transcoding-job-builder': 60000, // 1 minute
@ -557,7 +560,9 @@ const VIDEO_STATES: { [ id in VideoStateType ]: string } = {
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
[VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed',
[VideoState.TO_EDIT]: 'To edit*'
[VideoState.TO_EDIT]: 'To edit',
[VideoState.TO_MOVE_TO_FILE_SYSTEM]: 'To move to file system',
[VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED]: 'Move to file system failed'
}
const VIDEO_IMPORT_STATES: { [ id in VideoImportStateType ]: string } = {

View File

@ -0,0 +1,138 @@
import { Job } from 'bullmq'
import { join } from 'path'
import { MoveStoragePayload, VideoStateType, VideoStorage } 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 {
makeHLSFileAvailable,
makeWebVideoFileAvailable,
removeHLSFileObjectStorageByFilename,
removeHLSObjectStorage,
removeWebVideoObjectStorage
} 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 { moveToFailedMoveToFileSystemState, moveToNextState } from '@server/lib/video-state.js'
import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
import { moveToJob, onMoveToStorageFailure } 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,
moveWebVideoFiles,
moveHLSFiles,
doAfterLastMove: video => doAfterLastMove({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo }),
moveToFailedState: moveToFailedMoveToFileSystemState
})
}
export async function onMoveToFileSystemFailure (job: Job, err: any) {
const payload = job.data as MoveStoragePayload
await onMoveToStorageFailure({
videoUUID: payload.videoUUID,
err,
lTags: lTagsBase(),
moveToFailedState: moveToFailedMoveToFileSystemState
})
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function moveWebVideoFiles (video: MVideoWithAllFiles) {
for (const file of video.VideoFiles) {
if (file.storage === VideoStorage.FILE_SYSTEM) continue
await makeWebVideoFileAvailable(file.filename, VideoPathManager.Instance.getFSVideoFileOutputPath(video, file))
await onFileMoved({
videoOrPlaylist: video,
file,
objetStorageRemover: () => removeWebVideoObjectStorage(file)
})
}
}
async function moveHLSFiles (video: MVideoWithAllFiles) {
for (const playlist of video.VideoStreamingPlaylists) {
const playlistWithVideo = playlist.withVideo(video)
for (const file of playlist.VideoFiles) {
if (file.storage === VideoStorage.FILE_SYSTEM) continue
// Resolution playlist
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
await makeHLSFileAvailable(playlistWithVideo, playlistFilename, join(getHLSDirectory(video), playlistFilename))
await makeHLSFileAvailable(playlistWithVideo, file.filename, join(getHLSDirectory(video), file.filename))
await onFileMoved({
videoOrPlaylist: playlistWithVideo,
file,
objetStorageRemover: async () => {
await removeHLSFileObjectStorageByFilename(playlistWithVideo, playlistFilename)
await removeHLSFileObjectStorageByFilename(playlistWithVideo, file.filename)
}
})
}
}
}
async function onFileMoved (options: {
videoOrPlaylist: MVideo | MStreamingPlaylistVideo
file: MVideoFile
objetStorageRemover: () => Promise<any>
}) {
const { videoOrPlaylist, file, objetStorageRemover } = options
const oldFileUrl = file.fileUrl
file.fileUrl = null
file.storage = VideoStorage.FILE_SYSTEM
await updateTorrentMetadata(videoOrPlaylist, file)
await file.save()
logger.debug('Removing web video file %s because it\'s now on file system', oldFileUrl, lTagsBase())
await objetStorageRemover()
}
async function doAfterLastMove (options: {
video: MVideoWithAllFiles
previousVideoState: VideoStateType
isNewVideo: boolean
}) {
const { video, previousVideoState, isNewVideo } = options
for (const playlist of video.VideoStreamingPlaylists) {
if (playlist.storage === VideoStorage.FILE_SYSTEM) continue
const playlistWithVideo = playlist.withVideo(video)
for (const filename of [ playlist.playlistFilename, playlist.segmentsSha256Filename ]) {
await makeHLSFileAvailable(playlistWithVideo, filename, join(getHLSDirectory(video), filename))
}
playlist.playlistUrl = null
playlist.segmentsSha256Url = null
playlist.storage = VideoStorage.FILE_SYSTEM
playlist.assignP2PMediaLoaderInfoHashes(video, playlist.VideoFiles)
playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
await playlist.save()
await removeHLSObjectStorage(playlistWithVideo)
}
await moveToNextState({ video, previousVideoState, isNewVideo })
}

View File

@ -1,7 +1,7 @@
import { Job } from 'bullmq'
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import { MoveObjectStoragePayload, VideoStateType, VideoStorage } from '@peertube/peertube-models'
import { MoveStoragePayload, VideoStateType, VideoStorage } 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'
@ -9,68 +9,36 @@ import { storeHLSFileFromFilename, storeWebVideoFile } from '@server/lib/object-
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 { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoModel } from '@server/models/video/video.js'
import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoWithAllFiles } from '@server/types/models/index.js'
import { moveToJob, onMoveToStorageFailure } from './shared/move-video.js'
const lTagsBase = loggerTagsFactory('move-object-storage')
export async function processMoveToObjectStorage (job: Job) {
const payload = job.data as MoveObjectStoragePayload
logger.info('Moving video %s in job %s.', payload.videoUUID, job.id)
const payload = job.data as MoveStoragePayload
logger.info('Moving video %s to object storage in job %s.', payload.videoUUID, job.id)
const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(payload.videoUUID)
await moveToJob({
jobId: job.id,
videoUUID: payload.videoUUID,
loggerTags: lTagsBase().tags,
const video = await VideoModel.loadWithFiles(payload.videoUUID)
// No video, maybe deleted?
if (!video) {
logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
fileMutexReleaser()
return undefined
}
const lTags = lTagsBase(video.uuid, video.url)
try {
if (video.VideoFiles) {
logger.debug('Moving %d web video files for video %s.', video.VideoFiles.length, video.uuid, lTags)
await moveWebVideoFiles(video)
}
if (video.VideoStreamingPlaylists) {
logger.debug('Moving HLS playlist of %s.', video.uuid)
await moveHLSFiles(video)
}
const pendingMove = await VideoJobInfoModel.decrease(video.uuid, 'pendingMove')
if (pendingMove === 0) {
logger.info('Running cleanup after moving files to object storage (video %s in job %s)', video.uuid, job.id, lTags)
await doAfterLastJob({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo })
}
} catch (err) {
await onMoveToObjectStorageFailure(job, err)
throw err
} finally {
fileMutexReleaser()
}
return payload.videoUUID
moveWebVideoFiles,
moveHLSFiles,
doAfterLastMove: video => doAfterLastMove({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo }),
moveToFailedState: moveToFailedMoveToObjectStorageState
})
}
export async function onMoveToObjectStorageFailure (job: Job, err: any) {
const payload = job.data as MoveObjectStoragePayload
const payload = job.data as MoveStoragePayload
const video = await VideoModel.loadWithFiles(payload.videoUUID)
if (!video) return
logger.error('Cannot move video %s to object storage.', video.url, { err, ...lTagsBase(video.uuid, video.url) })
await moveToFailedMoveToObjectStorageState(video)
await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingMove')
await onMoveToStorageFailure({
videoUUID: payload.videoUUID,
err,
lTags: lTagsBase(),
moveToFailedState: moveToFailedMoveToObjectStorageState
})
}
// ---------------------------------------------------------------------------
@ -107,39 +75,6 @@ async function moveHLSFiles (video: MVideoWithAllFiles) {
}
}
async function doAfterLastJob (options: {
video: MVideoWithAllFiles
previousVideoState: VideoStateType
isNewVideo: boolean
}) {
const { video, previousVideoState, isNewVideo } = options
for (const playlist of video.VideoStreamingPlaylists) {
if (playlist.storage === VideoStorage.OBJECT_STORAGE) continue
const playlistWithVideo = playlist.withVideo(video)
// Master playlist
playlist.playlistUrl = await storeHLSFileFromFilename(playlistWithVideo, playlist.playlistFilename)
// Sha256 segments file
playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlistWithVideo, playlist.segmentsSha256Filename)
playlist.storage = VideoStorage.OBJECT_STORAGE
playlist.assignP2PMediaLoaderInfoHashes(video, playlist.VideoFiles)
playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
await playlist.save()
}
// Remove empty hls video directory
if (video.VideoStreamingPlaylists) {
await remove(getHLSDirectory(video))
}
await moveToNextState({ video, previousVideoState, isNewVideo })
}
async function onFileMoved (options: {
videoOrPlaylist: MVideo | MStreamingPlaylistVideo
file: MVideoFile
@ -154,6 +89,32 @@ async function onFileMoved (options: {
await updateTorrentMetadata(videoOrPlaylist, file)
await file.save()
logger.debug('Removing %s because it\'s now on object storage', oldPath)
logger.debug('Removing %s because it\'s now on object storage', oldPath, lTagsBase())
await remove(oldPath)
}
async function doAfterLastMove (options: {
video: MVideoWithAllFiles
previousVideoState: VideoStateType
isNewVideo: boolean
}) {
const { video, previousVideoState, isNewVideo } = options
for (const playlist of video.VideoStreamingPlaylists) {
if (playlist.storage === VideoStorage.OBJECT_STORAGE) continue
const playlistWithVideo = playlist.withVideo(video)
playlist.playlistUrl = await storeHLSFileFromFilename(playlistWithVideo, playlist.playlistFilename)
playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlistWithVideo, playlist.segmentsSha256Filename)
playlist.storage = VideoStorage.OBJECT_STORAGE
playlist.assignP2PMediaLoaderInfoHashes(video, playlist.VideoFiles)
playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
await playlist.save()
}
await remove(getHLSDirectory(video))
await moveToNextState({ video, previousVideoState, isNewVideo })
}

View File

@ -0,0 +1,76 @@
import { LoggerTags, logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoWithAllFiles } from '@server/types/models/index.js'
export async function moveToJob (options: {
jobId: string
videoUUID: string
loggerTags: string[]
moveWebVideoFiles: (video: MVideoWithAllFiles) => Promise<void>
moveHLSFiles: (video: MVideoWithAllFiles) => Promise<void>
moveToFailedState: (video: MVideoWithAllFiles) => Promise<void>
doAfterLastMove: (video: MVideoWithAllFiles) => Promise<void>
}) {
const { jobId, loggerTags, videoUUID, moveHLSFiles, moveWebVideoFiles, moveToFailedState, doAfterLastMove } = options
const lTagsBase = loggerTagsFactory(...loggerTags)
const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoUUID)
const video = await VideoModel.loadWithFiles(videoUUID)
// No video, maybe deleted?
if (!video) {
logger.info('Can\'t process job %d, video does not exist.', jobId, lTagsBase(videoUUID))
fileMutexReleaser()
return undefined
}
const lTags = lTagsBase(video.uuid, video.url)
try {
if (video.VideoFiles) {
logger.debug('Moving %d web video files for video %s.', video.VideoFiles.length, video.uuid, lTags)
await moveWebVideoFiles(video)
}
if (video.VideoStreamingPlaylists) {
logger.debug('Moving HLS playlist of %s.', video.uuid)
await moveHLSFiles(video)
}
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)
await doAfterLastMove(video)
}
} catch (err) {
await onMoveToStorageFailure({ videoUUID, err, lTags, moveToFailedState })
throw err
} finally {
fileMutexReleaser()
}
}
export async function onMoveToStorageFailure (options: {
videoUUID: string
err: any
lTags: LoggerTags
moveToFailedState: (video: MVideoWithAllFiles) => Promise<void>
}) {
const { videoUUID, err, lTags, moveToFailedState } = options
const video = await VideoModel.loadWithFiles(videoUUID)
if (!video) return
logger.error('Cannot move video %s storage.', video.url, { err, ...lTags })
await moveToFailedState(video)
await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingMove')
}

View File

@ -7,7 +7,7 @@ import { CONFIG } from '@server/initializers/config.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
import { generateWebVideoFilename } from '@server/lib/paths.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildMoveToObjectStorageJob } from '@server/lib/video.js'
import { buildMoveJob } from '@server/lib/video.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoFullLight } from '@server/types/models/index.js'
@ -30,7 +30,7 @@ async function processVideoFileImport (job: Job) {
await updateVideoFile(video, payload.filePath)
if (CONFIG.OBJECT_STORAGE.ENABLED) {
await JobQueue.Instance.createJob(await buildMoveToObjectStorageJob({ video, previousVideoState: video.state }))
await JobQueue.Instance.createJob(await buildMoveJob({ video, previousVideoState: video.state, type: 'move-to-object-storage' }))
} else {
await federateVideoIfNeeded(video, false)
}

View File

@ -25,7 +25,7 @@ import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-t
import { isAbleToUploadVideo } from '@server/lib/user.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { buildMoveToObjectStorageJob } from '@server/lib/video.js'
import { buildMoveJob } from '@server/lib/video.js'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js'
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
@ -317,7 +317,7 @@ async function afterImportSuccess (options: {
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
await JobQueue.Instance.createJob(
await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT })
await buildMoveJob({ video, previousVideoState: VideoState.TO_IMPORT, type: 'move-to-object-storage' })
)
return
}

View File

@ -25,7 +25,7 @@ import {
JobState,
JobType,
ManageVideoTorrentPayload,
MoveObjectStoragePayload,
MoveStoragePayload,
NotifyPayload,
RefreshPayload,
TranscodingJobBuilderPayload,
@ -70,6 +70,7 @@ import { processVideoLiveEnding } from './handlers/video-live-ending.js'
import { processVideoStudioEdition } from './handlers/video-studio-edition.js'
import { processVideoTranscoding } from './handlers/video-transcoding.js'
import { processVideosViewsStats } from './handlers/video-views-stats.js'
import { onMoveToFileSystemFailure, processMoveToFileSystem } from './handlers/move-to-file-system.js'
export type CreateJobArgument =
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@ -91,11 +92,11 @@ export type CreateJobArgument =
{ type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
{ type: 'video-studio-edition', payload: VideoStudioEditionPayload } |
{ type: 'manage-video-torrent', payload: ManageVideoTorrentPayload } |
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
{ type: 'move-to-object-storage', payload: MoveStoragePayload } |
{ type: 'move-to-file-system', payload: MoveStoragePayload } |
{ type: 'video-channel-import', payload: VideoChannelImportPayload } |
{ type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } |
{ type: 'notify', payload: NotifyPayload } |
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
{ type: 'federate-video', payload: FederateVideoPayload } |
{ type: 'generate-video-storyboard', payload: GenerateStoryboardPayload }
@ -120,6 +121,7 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
'transcoding-job-builder': processTranscodingJobBuilder,
'manage-video-torrent': processManageVideoTorrent,
'move-to-object-storage': processMoveToObjectStorage,
'move-to-file-system': processMoveToFileSystem,
'notify': processNotify,
'video-channel-import': processVideoChannelImport,
'video-file-import': processVideoFileImport,
@ -133,7 +135,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
}
const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
'move-to-object-storage': onMoveToObjectStorageFailure
'move-to-object-storage': onMoveToObjectStorageFailure,
'move-to-file-system': onMoveToFileSystemFailure
}
const jobTypes: JobType[] = [
@ -151,6 +154,7 @@ const jobTypes: JobType[] = [
'generate-video-storyboard',
'manage-video-torrent',
'move-to-object-storage',
'move-to-file-system',
'notify',
'transcoding-job-builder',
'video-channel-import',

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 { buildMoveToObjectStorageJob } from './video.js'
import { buildMoveJob } from './video.js'
function buildNextVideoState (currentState?: VideoStateType) {
if (currentState === VideoState.PUBLISHED) {
@ -21,6 +21,7 @@ function buildNextVideoState (currentState?: VideoStateType) {
currentState !== VideoState.TO_EDIT &&
currentState !== VideoState.TO_TRANSCODE &&
currentState !== VideoState.TO_MOVE_TO_EXTERNAL_STORAGE &&
currentState !== VideoState.TO_MOVE_TO_FILE_SYSTEM &&
CONFIG.TRANSCODING.ENABLED
) {
return VideoState.TO_TRANSCODE
@ -28,6 +29,7 @@ function buildNextVideoState (currentState?: VideoStateType) {
if (
currentState !== VideoState.TO_MOVE_TO_EXTERNAL_STORAGE &&
currentState !== VideoState.TO_MOVE_TO_FILE_SYSTEM &&
CONFIG.OBJECT_STORAGE.ENABLED
) {
return VideoState.TO_MOVE_TO_EXTERNAL_STORAGE
@ -68,6 +70,8 @@ function moveToNextState (options: {
})
}
// ---------------------------------------------------------------------------
async function moveToExternalStorageState (options: {
video: MVideoFullLight
isNewVideo: boolean
@ -90,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 buildMoveToObjectStorageJob({ video, previousVideoState, isNewVideo }))
await JobQueue.Instance.createJob(await buildMoveJob({ video, previousVideoState, isNewVideo, type: 'move-to-object-storage' }))
return true
} catch (err) {
@ -100,6 +104,34 @@ async function moveToExternalStorageState (options: {
}
}
async function moveToFileSystemState (options: {
video: MVideoFullLight
isNewVideo: boolean
transaction: Transaction
}) {
const { video, isNewVideo, transaction } = options
const previousVideoState = video.state
if (video.state !== VideoState.TO_MOVE_TO_FILE_SYSTEM) {
await video.setNewState(VideoState.TO_MOVE_TO_FILE_SYSTEM, false, transaction)
}
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' }))
return true
} catch (err) {
logger.error('Cannot add move to file system job', { err })
return false
}
}
// ---------------------------------------------------------------------------
function moveToFailedTranscodingState (video: MVideo) {
if (video.state === VideoState.TRANSCODING_FAILED) return
@ -112,11 +144,19 @@ function moveToFailedMoveToObjectStorageState (video: MVideo) {
return video.setNewState(VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED, false, undefined)
}
function moveToFailedMoveToFileSystemState (video: MVideo) {
if (video.state === VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED) return
return video.setNewState(VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED, false, undefined)
}
// ---------------------------------------------------------------------------
export {
buildNextVideoState,
moveToFailedMoveToFileSystemState,
moveToExternalStorageState,
moveToFileSystemState,
moveToFailedTranscodingState,
moveToFailedMoveToObjectStorageState,
moveToNextState

View File

@ -21,7 +21,7 @@ import { CreateJobArgument, JobQueue } from './job-queue/job-queue.js'
import { updateLocalVideoMiniatureFromExisting } from './thumbnail.js'
import { moveFilesIfPrivacyChanged } from './video-privacy.js'
function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
export function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
return {
name: videoInfo.name,
remote: false,
@ -42,7 +42,7 @@ function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): Fil
}
}
async function buildVideoThumbnailsFromReq (options: {
export async function buildVideoThumbnailsFromReq (options: {
video: MVideoThumbnail
files: UploadFiles
fallback: (type: ThumbnailType_Type) => Promise<MThumbnail>
@ -79,7 +79,7 @@ async function buildVideoThumbnailsFromReq (options: {
// ---------------------------------------------------------------------------
async function setVideoTags (options: {
export async function setVideoTags (options: {
video: MVideoTag
tags: string[]
transaction?: Transaction
@ -95,17 +95,18 @@ async function setVideoTags (options: {
// ---------------------------------------------------------------------------
async function buildMoveToObjectStorageJob (options: {
export async function buildMoveJob (options: {
video: MVideoUUID
previousVideoState: VideoStateType
type: 'move-to-object-storage' | 'move-to-file-system'
isNewVideo?: boolean // Default true
}) {
const { video, previousVideoState, isNewVideo = true } = options
const { video, previousVideoState, isNewVideo = true, type } = options
await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingMove')
return {
type: 'move-to-object-storage' as 'move-to-object-storage',
type,
payload: {
videoUUID: video.uuid,
isNewVideo,
@ -116,7 +117,7 @@ async function buildMoveToObjectStorageJob (options: {
// ---------------------------------------------------------------------------
async function getVideoDuration (videoId: number | string) {
export async function getVideoDuration (videoId: number | string) {
const video = await VideoModel.load(videoId)
const duration = video.isLive
@ -126,7 +127,7 @@ async function getVideoDuration (videoId: number | string) {
return { duration, isLive: video.isLive }
}
const getCachedVideoDuration = memoizee(getVideoDuration, {
export const getCachedVideoDuration = memoizee(getVideoDuration, {
promise: true,
max: MEMOIZE_LENGTH.VIDEO_DURATION,
maxAge: MEMOIZE_TTL.VIDEO_DURATION
@ -134,7 +135,7 @@ const getCachedVideoDuration = memoizee(getVideoDuration, {
// ---------------------------------------------------------------------------
async function addVideoJobsAfterUpdate (options: {
export async function addVideoJobsAfterUpdate (options: {
video: MVideoFullLight
isNewVideo: boolean
@ -188,14 +189,3 @@ async function addVideoJobsAfterUpdate (options: {
return JobQueue.Instance.createSequentialJobFlow(...jobs)
}
// ---------------------------------------------------------------------------
export {
buildLocalVideoFromReq,
buildVideoThumbnailsFromReq,
setVideoTags,
buildMoveToObjectStorageJob,
addVideoJobsAfterUpdate,
getCachedVideoDuration
}

View File

@ -89,6 +89,7 @@ export function checkVideoFileCanBeEdited (video: MVideo, res: express.Response)
const validStates = new Set<VideoStateType>([
VideoState.PUBLISHED,
VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED,
VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED,
VideoState.TRANSCODING_FAILED
])

View File

@ -3,21 +3,23 @@ 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 } from '@server/lib/video-state.js'
import { moveToExternalStorageState, moveToFileSystemState } from '@server/lib/video-state.js'
import { VideoModel } from '@server/models/video/video.js'
import { VideoState, VideoStorage } from '@peertube/peertube-models'
import { MStreamingPlaylist, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
program
.description('Move videos to another storage.')
.option('-o, --to-object-storage', 'Move videos in object storage')
.option('-f, --to-file-system', 'Move videos to file system')
.option('-v, --video [videoUUID]', 'Move a specific video')
.option('-a, --all-videos', 'Migrate all videos')
.parse(process.argv)
const options = program.opts()
if (!options['toObjectStorage']) {
console.error('You need to choose where to send video files.')
if (!options['toObjectStorage'] && !options['toFileSystem']) {
console.error('You need to choose where to send video files using --to-object-storage or --to-file-system.')
process.exit(-1)
}
@ -63,8 +65,8 @@ async function run () {
process.exit(-1)
}
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
console.error('This video is already being moved to external storage')
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE || video.state === VideoState.TO_MOVE_TO_FILE_SYSTEM) {
console.error('This video is already being moved to external storage/file system')
process.exit(-1)
}
@ -75,25 +77,58 @@ async function run () {
for (const id of ids) {
const videoFull = await VideoModel.loadFull(id)
if (videoFull.isLive) continue
const files = videoFull.VideoFiles || []
const hls = videoFull.getHLSPlaylist()
if (options['toObjectStorage']) {
await createMoveJobIfNeeded({
video: videoFull,
type: 'to object storage',
canProcessVideo: (files, hls) => {
return files.some(f => f.storage === VideoStorage.FILE_SYSTEM) || hls?.storage === VideoStorage.FILE_SYSTEM
},
handler: () => moveToExternalStorageState({ video: videoFull, isNewVideo: false, transaction: undefined })
})
if (files.some(f => f.storage === VideoStorage.FILE_SYSTEM) || hls?.storage === VideoStorage.FILE_SYSTEM) {
console.log('Processing video %s.', videoFull.name)
const success = await moveToExternalStorageState({ video: videoFull, isNewVideo: false, transaction: undefined })
if (!success) {
console.error(
'Cannot create move job for %s: job creation may have failed or there may be pending transcoding jobs for this video',
videoFull.name
)
}
continue
}
console.log(`Created move-to-object-storage job for ${videoFull.name}.`)
if (options['toFileSystem']) {
await createMoveJobIfNeeded({
video: videoFull,
type: 'to file system',
canProcessVideo: (files, hls) => {
return files.some(f => f.storage === VideoStorage.OBJECT_STORAGE) || hls?.storage === VideoStorage.OBJECT_STORAGE
},
handler: () => moveToFileSystemState({ video: videoFull, isNewVideo: false, transaction: undefined })
})
}
}
}
async function createMoveJobIfNeeded (options: {
video: MVideoFullLight
type: 'to object storage' | 'to file system'
canProcessVideo: (files: MVideoFile[], hls: MStreamingPlaylist) => boolean
handler: () => Promise<any>
}) {
const { video, type, canProcessVideo, handler } = options
const files = video.VideoFiles || []
const hls = video.getHLSPlaylist()
if (canProcessVideo(files, hls)) {
console.log(`Moving ${type} video ${video.name}`)
const success = await handler()
if (!success) {
console.error(
`Cannot create move ${type} for ${video.name}: job creation may have failed or there may be pending transcoding jobs for this video`
)
} else {
console.log(`Created job ${type} for ${video.name}.`)
}
}
}

View File

@ -18,5 +18,8 @@
],
"include": [
"./**/*.ts"
],
"exclude": [
"./dist/**/*.ts"
]
}