Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for saving video files to object storage #4290

Merged
merged 24 commits into from
Aug 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4145ba0
Add support for saving video files to object storage
pingiun Jul 30, 2021
4f5d19b
Add support for custom url generation on s3 stored files
pingiun Aug 1, 2021
67f7339
Upload files to s3 concurrently and delete originals afterwards
pingiun Aug 1, 2021
340dc0c
Only publish after move to object storage is complete
pingiun Aug 1, 2021
9432222
Use base url instead of url template
pingiun Aug 2, 2021
70e1ef7
Fix mistyped config field
pingiun Aug 3, 2021
121f8b5
Add rudenmentary way to download before transcode
pingiun Aug 3, 2021
2374244
Implement Chocobozzz suggestions
pingiun Aug 3, 2021
7d788e7
Import correct function
pingiun Aug 3, 2021
14fb34b
Support multipart upload
pingiun Aug 3, 2021
9422fd9
Remove import of node 15.0 module stream/promises
pingiun Aug 6, 2021
09cfb18
Extend maximum upload job length
pingiun Aug 6, 2021
25d6608
Use dynamic part size for really large uploads
pingiun Aug 6, 2021
6b5c9ca
Fix decreasePendingMove query
pingiun Aug 6, 2021
2a26bb7
Resolve various PR comments
pingiun Aug 6, 2021
24c45a5
Move to object storage after optimize
pingiun Aug 6, 2021
50d6556
Make upload size configurable and increase default
pingiun Aug 7, 2021
a54e28a
Prune webtorrent files that are stored in object storage
pingiun Aug 9, 2021
72eb147
Move files after transcoding jobs
Chocobozzz Aug 12, 2021
b884938
Merge branch 'develop' into add-object-storage-support
Chocobozzz Aug 13, 2021
831f973
Fix federation
Chocobozzz Aug 13, 2021
2b71af9
Add video path manager
Chocobozzz Aug 16, 2021
ffa0c8c
Support move to external storage job in client
Chocobozzz Aug 16, 2021
6b7c3bc
Fix live object storage tests
Chocobozzz Aug 16, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ jobs:
ports:
- 10389:10389

s3ninja:
image: scireum/s3-ninja
ports:
- 9444:9000

strategy:
fail-fast: false
matrix:
Expand All @@ -40,6 +45,7 @@ jobs:
PGUSER: peertube
PGHOST: localhost
NODE_PENDING_JOB_WAIT: 250
ENABLE_OBJECT_STORAGE_TESTS: true

steps:
- uses: actions/checkout@v2
Expand Down
3 changes: 2 additions & 1 deletion client/src/app/+admin/system/jobs/jobs.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export class JobsComponent extends RestTable implements OnInit {
'video-live-ending',
'video-redundancy',
'video-transcoding',
'videos-views'
'videos-views',
'move-to-object-storage'
]

jobs: Job[] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
The video is being transcoded, 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 alert-info" *ngIf="hasVideoScheduledPublication()">
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export class VideoAlertComponent {
return this.video && this.video.state.id === VideoState.TO_IMPORT
}

isVideoToMoveToExternalStorage () {
return this.video && this.video.state.id === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE
}

hasVideoScheduledPublication () {
return this.video && this.video.scheduledUpdate !== undefined
}
Expand Down
33 changes: 33 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,39 @@ storage:
# If not, peertube will fallback to the default fil
client_overrides: 'storage/client-overrides/'

object_storage:
enabled: false

# Without protocol, will default to HTTPS
endpoint: '' # 's3.amazonaws.com' or 's3.fr-par.scw.cloud' for example

region: 'us-east-1'

credentials:
# You can also use AWS_ACCESS_KEY_ID env variable
access_key_id: ''
# You can also use AWS_SECRET_ACCESS_KEY env variable
secret_access_key: ''

# Maximum amount to upload in one request to object storage
max_upload_part: 2GB

streaming_playlists:
bucket_name: 'streaming-playlists'

# Allows setting all buckets to the same value but with a different prefix
prefix: '' # Example: 'streaming-playlists:'

# Base url for object URL generation, scheme and host will be replaced by this URL
# Useful when you want to use a CDN/external proxy
base_url: '' # Example: 'https://mirror.example.com'

# Same settings but for webtorrent videos
videos:
bucket_name: 'videos'
prefix: ''
base_url: ''

log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error'
rotation:
Expand Down
33 changes: 33 additions & 0 deletions config/production.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,39 @@ storage:
# If not, peertube will fallback to the default file
client_overrides: '/var/www/peertube/storage/client-overrides/'

object_storage:
enabled: false

# Without protocol, will default to HTTPS
endpoint: '' # 's3.amazonaws.com' or 's3.fr-par.scw.cloud' for example

region: 'us-east-1'

credentials:
# You can also use AWS_ACCESS_KEY_ID env variable
access_key_id: ''
# You can also use AWS_SECRET_ACCESS_KEY env variable
secret_access_key: ''

# Maximum amount to upload in one request to object storage
max_upload_part: 2GB

streaming_playlists:
bucket_name: 'streaming-playlists'

# Allows setting all buckets to the same value but with a different prefix
prefix: '' # Example: 'streaming-playlists:'

# Base url for object URL generation, scheme and host will be replaced by this URL
# Useful when you want to use a CDN/external proxy
base_url: '' # Example: 'https://mirror.example.com'

# Same settings but for webtorrent videos
videos:
bucket_name: 'videos'
prefix: ''
base_url: ''

log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error'
rotation:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"swagger-cli": "swagger-cli"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.23.0",
"@uploadx/core": "^4.4.0",
"async": "^3.0.1",
"async-lru": "^1.1.1",
Expand Down
3 changes: 2 additions & 1 deletion scripts/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ elif [ "$1" = "api-4" ]; then

moderationFiles=$(findTestFiles ./dist/server/tests/api/moderation)
redundancyFiles=$(findTestFiles ./dist/server/tests/api/redundancy)
objectStorageFiles=$(findTestFiles ./dist/server/tests/api/object-storage)
activitypubFiles=$(findTestFiles ./dist/server/tests/api/activitypub)

MOCHA_PARALLEL=true TS_NODE_FILES=true runTest "$1" 2 $moderationFiles $redundancyFiles $activitypubFiles
MOCHA_PARALLEL=true TS_NODE_FILES=true runTest "$1" 2 $moderationFiles $redundancyFiles $activitypubFiles $objectStorageFiles
elif [ "$1" = "external-plugins" ]; then
npm run build:server

Expand Down
13 changes: 9 additions & 4 deletions scripts/create-transcoding-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { VideoModel } from '../server/models/video/video'
import { initDatabaseModels } from '../server/initializers/database'
import { JobQueue } from '../server/lib/job-queue'
import { computeResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
import { VideoTranscodingPayload } from '@shared/models'
import { VideoState, VideoTranscodingPayload } from '@shared/models'
import { CONFIG } from '@server/initializers/config'
import { isUUIDValid } from '@server/helpers/custom-validators/misc'
import { addTranscodingJob } from '@server/lib/video'

program
.option('-v, --video [videoUUID]', 'Video UUID')
Expand Down Expand Up @@ -47,7 +48,7 @@ async function run () {
if (!video) throw new Error('Video not found.')

const dataInput: VideoTranscodingPayload[] = []
const { resolution } = await video.getMaxQualityResolution()
const resolution = video.getMaxQualityFile().resolution

// Generate HLS files
if (options.generateHls || CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) {
Expand All @@ -62,6 +63,7 @@ async function run () {
resolution,
isPortraitMode: false,
copyCodecs: false,
isNewVideo: false,
isMaxQuality: false
})
}
Expand All @@ -87,10 +89,13 @@ async function run () {
}
}

await JobQueue.Instance.init()
JobQueue.Instance.init()

video.state = VideoState.TO_TRANSCODE
await video.save()

for (const d of dataInput) {
await JobQueue.Instance.createJobWithPromise({ type: 'video-transcoding', payload: d })
await addTranscodingJob(d, {})
console.log('Transcoding job for video %s created.', video.uuid)
}
}
91 changes: 50 additions & 41 deletions scripts/optimize-old-videos.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { registerTSPaths } from '../server/helpers/register-ts-paths'
registerTSPaths()

import { getDurationFromVideoFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../server/helpers/ffprobe-utils'
import { VideoModel } from '../server/models/video/video'
import { optimizeOriginalVideofile } from '../server/lib/transcoding/video-transcoding'
import { initDatabaseModels } from '../server/initializers/database'
import { basename, dirname } from 'path'
import { copy, move, remove } from 'fs-extra'
import { basename, dirname } from 'path'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { getVideoFilePath } from '@server/lib/video-paths'
import { CONFIG } from '@server/initializers/config'
import { processMoveToObjectStorage } from '@server/lib/job-queue/handlers/move-to-object-storage'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { getMaxBitrate } from '@shared/core-utils'
import { MoveObjectStoragePayload } from '@shared/models'
import { getDurationFromVideoFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../server/helpers/ffprobe-utils'
import { initDatabaseModels } from '../server/initializers/database'
import { optimizeOriginalVideofile } from '../server/lib/transcoding/video-transcoding'
import { VideoModel } from '../server/models/video/video'

run()
.then(() => process.exit(0))
Expand Down Expand Up @@ -39,43 +42,49 @@ async function run () {
currentVideoId = video.id

for (const file of video.VideoFiles) {
currentFilePath = getVideoFilePath(video, file)

const [ videoBitrate, fps, dataResolution ] = await Promise.all([
getVideoFileBitrate(currentFilePath),
getVideoFileFPS(currentFilePath),
getVideoFileResolution(currentFilePath)
])

const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
const isMaxBitrateExceeded = videoBitrate > maxBitrate
if (isMaxBitrateExceeded) {
console.log(
'Optimizing video file %s with bitrate %s kbps (max: %s kbps)',
basename(currentFilePath), videoBitrate / 1000, maxBitrate / 1000
)

const backupFile = `${currentFilePath}_backup`
await copy(currentFilePath, backupFile)

await optimizeOriginalVideofile(video, file)
// Update file path, the video filename changed
currentFilePath = getVideoFilePath(video, file)

const originalDuration = await getDurationFromVideoFile(backupFile)
const newDuration = await getDurationFromVideoFile(currentFilePath)

if (originalDuration === newDuration) {
console.log('Finished optimizing %s', basename(currentFilePath))
await remove(backupFile)
continue
await VideoPathManager.Instance.makeAvailableVideoFile(video, file, async path => {
currentFilePath = path

const [ videoBitrate, fps, dataResolution ] = await Promise.all([
getVideoFileBitrate(currentFilePath),
getVideoFileFPS(currentFilePath),
getVideoFileResolution(currentFilePath)
])

const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
const isMaxBitrateExceeded = videoBitrate > maxBitrate
if (isMaxBitrateExceeded) {
console.log(
'Optimizing video file %s with bitrate %s kbps (max: %s kbps)',
basename(currentFilePath), videoBitrate / 1000, maxBitrate / 1000
)

const backupFile = `${currentFilePath}_backup`
await copy(currentFilePath, backupFile)

await optimizeOriginalVideofile(video, file)
// Update file path, the video filename changed
currentFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, file)

const originalDuration = await getDurationFromVideoFile(backupFile)
const newDuration = await getDurationFromVideoFile(currentFilePath)

if (originalDuration === newDuration) {
console.log('Finished optimizing %s', basename(currentFilePath))
await remove(backupFile)
return
}

console.log('Failed to optimize %s, restoring original', basename(currentFilePath))
await move(backupFile, currentFilePath, { overwrite: true })
await createTorrentAndSetInfoHash(video, file)
await file.save()
}
})
}

console.log('Failed to optimize %s, restoring original', basename(currentFilePath))
await move(backupFile, currentFilePath, { overwrite: true })
await createTorrentAndSetInfoHash(video, file)
await file.save()
}
if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
await processMoveToObjectStorage({ data: { videoUUID: video.uuid } as MoveObjectStoragePayload } as any)
}
}

Expand Down
34 changes: 22 additions & 12 deletions server/controllers/api/videos/upload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import * as express from 'express'
import { move } from 'fs-extra'
import { basename } from 'path'
import { getLowercaseExtension } from '@server/helpers/core-utils'
import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
import { uuidToShort } from '@server/helpers/uuid'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
import { generateWebTorrentVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
import {
addMoveToObjectStorageJob,
addOptimizeOrMergeAudioJob,
buildLocalVideoFromReq,
buildVideoThumbnailsFromReq,
setVideoTags
} from '@server/lib/video'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { buildNextVideoState } from '@server/lib/video-state'
import { openapiOperationDoc } from '@server/middlewares/doc'
import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
import { uploadx } from '@uploadx/core'
Expand Down Expand Up @@ -139,23 +148,20 @@ async function addVideo (options: {

const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)

videoData.state = CONFIG.TRANSCODING.ENABLED
? VideoState.TO_TRANSCODE
: VideoState.PUBLISHED

videoData.state = buildNextVideoState()
videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware

const video = new VideoModel(videoData) as MVideoFullLight
video.VideoChannel = videoChannel
video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object

const videoFile = await buildNewFile(video, videoPhysicalFile)
const videoFile = await buildNewFile(videoPhysicalFile)

// Move physical file
const destination = getVideoFilePath(video, videoFile)
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
await move(videoPhysicalFile.path, destination)
// This is important in case if there is another attempt in the retry process
videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
videoPhysicalFile.filename = basename(destination)
videoPhysicalFile.path = destination

const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
Expand Down Expand Up @@ -210,9 +216,13 @@ async function addVideo (options: {

createTorrentFederate(video, videoFile)
.then(() => {
if (video.state !== VideoState.TO_TRANSCODE) return
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
return addMoveToObjectStorageJob(video)
}

return addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
if (video.state === VideoState.TO_TRANSCODE) {
return addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
}
})
.catch(err => logger.error('Cannot add optimize/merge audio job for %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))

Expand All @@ -227,7 +237,7 @@ async function addVideo (options: {
})
}

async function buildNewFile (video: MVideo, videoPhysicalFile: express.VideoUploadFile) {
async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
const videoFile = new VideoFileModel({
extname: getLowercaseExtension(videoPhysicalFile.filename),
size: videoPhysicalFile.size,
Expand Down
Loading