Support update object storage urls
This commit is contained in:
parent
96b9748585
commit
3427330611
|
@ -47,6 +47,7 @@
|
||||||
"plugin:install": "node ./dist/scripts/plugin/install.js",
|
"plugin:install": "node ./dist/scripts/plugin/install.js",
|
||||||
"plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js",
|
"plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js",
|
||||||
"reset-password": "node ./dist/scripts/reset-password.js",
|
"reset-password": "node ./dist/scripts/reset-password.js",
|
||||||
|
"update-object-storage-url": "LOGGER_LEVEL=warn node ./dist/scripts/update-object-storage-url.js",
|
||||||
"update-host": "node ./dist/scripts/update-host.js",
|
"update-host": "node ./dist/scripts/update-host.js",
|
||||||
"regenerate-thumbnails": "node ./dist/scripts/regenerate-thumbnails.js",
|
"regenerate-thumbnails": "node ./dist/scripts/regenerate-thumbnails.js",
|
||||||
"create-import-video-file-job": "node ./dist/scripts/create-import-video-file-job.js",
|
"create-import-video-file-job": "node ./dist/scripts/create-import-video-file-job.js",
|
||||||
|
|
|
@ -11,6 +11,7 @@ export interface UserExport {
|
||||||
// In bytes
|
// In bytes
|
||||||
size: number
|
size: number
|
||||||
|
|
||||||
|
fileUrl: string
|
||||||
privateDownloadUrl: string
|
privateDownloadUrl: string
|
||||||
|
|
||||||
createdAt: string | Date
|
createdAt: string | Date
|
||||||
|
|
|
@ -10,6 +10,7 @@ export interface VideoSource {
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
|
|
||||||
|
fileUrl: string
|
||||||
fileDownloadUrl: string
|
fileDownloadUrl: string
|
||||||
|
|
||||||
fps?: number
|
fps?: number
|
||||||
|
|
|
@ -8,3 +8,4 @@ import './prune-storage'
|
||||||
import './regenerate-thumbnails'
|
import './regenerate-thumbnails'
|
||||||
import './reset-password'
|
import './reset-password'
|
||||||
import './update-host'
|
import './update-host'
|
||||||
|
import './update-object-storage-url.js'
|
||||||
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { getHLS } from '@peertube/peertube-core-utils'
|
||||||
|
import { VideoDetails } from '@peertube/peertube-models'
|
||||||
|
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
|
import {
|
||||||
|
ObjectStorageCommand,
|
||||||
|
PeerTubeServer,
|
||||||
|
cleanupTests,
|
||||||
|
createSingleServer,
|
||||||
|
getRedirectionUrl,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
waitJobs
|
||||||
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { expectStartWith } from '@tests/shared/checks.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
describe('Update object storage URL', function () {
|
||||||
|
if (areMockObjectStorageTestsDisabled()) return
|
||||||
|
|
||||||
|
let server: PeerTubeServer
|
||||||
|
let uuid: string
|
||||||
|
const objectStorage = new ObjectStorageCommand()
|
||||||
|
|
||||||
|
function runUpdate (from: string, to: string) {
|
||||||
|
const env = server.cli.getEnv()
|
||||||
|
const command = `echo y | ${env} npm run update-object-storage-url -- --from "${from}" --to "${to}"`
|
||||||
|
|
||||||
|
return server.cli.execWithEnv(command, objectStorage.getDefaultMockConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(360000)
|
||||||
|
|
||||||
|
server = await createSingleServer(1, objectStorage.getDefaultMockConfig())
|
||||||
|
await setAccessTokensToServers([ server ])
|
||||||
|
|
||||||
|
await objectStorage.prepareDefaultMockBuckets()
|
||||||
|
|
||||||
|
await server.config.enableMinimumTranscoding({ keepOriginal: true })
|
||||||
|
|
||||||
|
const video = await server.videos.quickUpload({ name: 'video' })
|
||||||
|
uuid = video.uuid
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update video URLs', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const check = async (options: {
|
||||||
|
baseUrl: string
|
||||||
|
newBaseUrl: string
|
||||||
|
urlGetter: (video: VideoDetails) => Promise<string[]> | string[]
|
||||||
|
}) => {
|
||||||
|
const { baseUrl, newBaseUrl, urlGetter } = options
|
||||||
|
|
||||||
|
const oldVideo = await server.videos.get({ id: uuid })
|
||||||
|
const oldFileUrls = await urlGetter(oldVideo)
|
||||||
|
|
||||||
|
for (const url of oldFileUrls) {
|
||||||
|
expectStartWith(url, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
await runUpdate(baseUrl, newBaseUrl)
|
||||||
|
|
||||||
|
const newVideo = await server.videos.get({ id: uuid })
|
||||||
|
|
||||||
|
const shouldBe = oldFileUrls.map(f => f.replace(baseUrl, newBaseUrl))
|
||||||
|
expect(await urlGetter(newVideo)).to.have.members(shouldBe)
|
||||||
|
}
|
||||||
|
|
||||||
|
await check({
|
||||||
|
baseUrl: objectStorage.getMockWebVideosBaseUrl(),
|
||||||
|
newBaseUrl: 'https://web-video.example.com/',
|
||||||
|
urlGetter: video => video.files.map(f => f.fileUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
await check({
|
||||||
|
baseUrl: objectStorage.getMockPlaylistBaseUrl(),
|
||||||
|
newBaseUrl: 'https://streaming-playlists.example.com/',
|
||||||
|
urlGetter: video => {
|
||||||
|
const hls = getHLS(video)
|
||||||
|
|
||||||
|
return [
|
||||||
|
...hls.files.map(f => f.fileUrl),
|
||||||
|
|
||||||
|
hls.playlistUrl,
|
||||||
|
hls.segmentsSha256Url
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await check({
|
||||||
|
baseUrl: objectStorage.getMockOriginalFileBaseUrl(),
|
||||||
|
newBaseUrl: 'https://original-file.example.com/',
|
||||||
|
urlGetter: async video => {
|
||||||
|
const source = await server.videos.getSource({ id: video.uuid })
|
||||||
|
|
||||||
|
return [ source.fileUrl ]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update user export URLs', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const user = await server.users.getMyInfo()
|
||||||
|
|
||||||
|
await server.userExports.request({ userId: user.id, withVideoFiles: false })
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data } = await server.userExports.list({ userId: user.id })
|
||||||
|
expectStartWith(data[0].fileUrl, objectStorage.getMockUserExportBaseUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
await runUpdate(objectStorage.getMockUserExportBaseUrl(), 'https://user-export.example.com/')
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data } = await server.userExports.list({ userId: user.id })
|
||||||
|
expectStartWith(data[0].fileUrl, 'https://user-export.example.com/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await objectStorage.cleanupMock()
|
||||||
|
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -27,10 +27,6 @@ export function wordsToRegExp (words: string[]) {
|
||||||
return new RegExp(`(?:\\P{L}|^)(?:${innerRegex})(?=\\P{L}|$)`, 'iu')
|
return new RegExp(`(?:\\P{L}|^)(?:${innerRegex})(?=\\P{L}|$)`, 'iu')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export function escapeForRegex (value: string) {
|
||||||
// Private
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function escapeForRegex (value: string) {
|
|
||||||
return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ export class VideoViewerCounters {
|
||||||
private processingViewerCounters = false
|
private processingViewerCounters = false
|
||||||
|
|
||||||
constructor () {
|
constructor () {
|
||||||
setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER_COUNTER)
|
setInterval(() => this.updateVideoViewersCount(), VIEW_LIFETIME.VIEWER_COUNTER)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -163,11 +163,13 @@ export class VideoViewerCounters {
|
||||||
return viewer
|
return viewer
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanViewerCounters () {
|
private async updateVideoViewersCount () {
|
||||||
if (this.processingViewerCounters) return
|
if (this.processingViewerCounters) return
|
||||||
this.processingViewerCounters = true
|
this.processingViewerCounters = true
|
||||||
|
|
||||||
if (!isTestOrDevInstance()) logger.info('Cleaning video viewers.', lTags())
|
if (!isTestOrDevInstance()) {
|
||||||
|
logger.debug('Updating video viewer counters.', lTags())
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const videoId of this.viewersPerVideo.keys()) {
|
for (const videoId of this.viewersPerVideo.keys()) {
|
||||||
|
|
|
@ -219,6 +219,7 @@ export class UserExportModel extends SequelizeModel<UserExportModel> {
|
||||||
|
|
||||||
size: this.size,
|
size: this.size,
|
||||||
|
|
||||||
|
fileUrl: this.fileUrl,
|
||||||
privateDownloadUrl: this.getFileDownloadUrl(),
|
privateDownloadUrl: this.getFileDownloadUrl(),
|
||||||
createdAt: this.createdAt.toISOString(),
|
createdAt: this.createdAt.toISOString(),
|
||||||
expiresOn: new Date(this.createdAt.getTime() + CONFIG.EXPORT.USERS.EXPORT_EXPIRATION).toISOString()
|
expiresOn: new Date(this.createdAt.getTime() + CONFIG.EXPORT.USERS.EXPORT_EXPIRATION).toISOString()
|
||||||
|
|
|
@ -125,6 +125,8 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
|
||||||
return {
|
return {
|
||||||
filename: this.inputFilename,
|
filename: this.inputFilename,
|
||||||
inputFilename: this.inputFilename,
|
inputFilename: this.inputFilename,
|
||||||
|
|
||||||
|
fileUrl: this.fileUrl,
|
||||||
fileDownloadUrl: this.getFileDownloadUrl(),
|
fileDownloadUrl: this.getFileDownloadUrl(),
|
||||||
|
|
||||||
resolution: {
|
resolution: {
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
/* eslint-disable max-len */
|
||||||
|
import { InvalidArgumentError, createCommand } from '@commander-js/extra-typings'
|
||||||
|
import { FileStorage } from '@peertube/peertube-models'
|
||||||
|
import { escapeForRegex } from '@server/helpers/regexp.js'
|
||||||
|
import { initDatabaseModels, sequelizeTypescript } from '@server/initializers/database.js'
|
||||||
|
import { QueryTypes } from 'sequelize'
|
||||||
|
import prompt from 'prompt'
|
||||||
|
|
||||||
|
const program = createCommand()
|
||||||
|
.description('Update PeerTube object file URLs after an object storage migration.')
|
||||||
|
.requiredOption('-f, --from <url>', 'Previous object storage base URL', parseUrl)
|
||||||
|
.requiredOption('-t, --to <url>', 'New object storage base URL', parseUrl)
|
||||||
|
.parse(process.argv)
|
||||||
|
|
||||||
|
const options = program.opts()
|
||||||
|
|
||||||
|
run()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function run () {
|
||||||
|
await initDatabaseModels(true)
|
||||||
|
|
||||||
|
const fromRegexp = `^${escapeForRegex(options.from)}`
|
||||||
|
const to = options.to
|
||||||
|
|
||||||
|
const replacements = { fromRegexp, to, storage: FileStorage.OBJECT_STORAGE }
|
||||||
|
|
||||||
|
// Candidates
|
||||||
|
{
|
||||||
|
const queries = [
|
||||||
|
`SELECT COUNT(*) AS "c", 'videoFile->fileUrl: ' || COUNT(*) AS "t" FROM "videoFile" WHERE "fileUrl" ~ :fromRegexp AND "storage" = :storage`,
|
||||||
|
`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`
|
||||||
|
]
|
||||||
|
|
||||||
|
let hasResults = false
|
||||||
|
|
||||||
|
console.log('Candidate URLs to update:')
|
||||||
|
for (const query of queries) {
|
||||||
|
const [ row ] = await sequelizeTypescript.query(query, { replacements, type: QueryTypes.SELECT as QueryTypes.SELECT })
|
||||||
|
|
||||||
|
if (row['c'] !== 0) hasResults = true
|
||||||
|
|
||||||
|
console.log(` ${row['t']}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n')
|
||||||
|
|
||||||
|
if (!hasResults) {
|
||||||
|
console.log('No candidate URLs found, exiting.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await askConfirmation()
|
||||||
|
if (res !== true) {
|
||||||
|
console.log('Exiting without updating URLs.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
{
|
||||||
|
const queries = [
|
||||||
|
`UPDATE "videoFile" SET "fileUrl" = regexp_replace("fileUrl", :fromRegexp, :to) WHERE "storage" = :storage`,
|
||||||
|
`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`
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
await sequelizeTypescript.query(query, { replacements })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('URLs updated.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUrl (value: string) {
|
||||||
|
if (!value || /^https?:\/\//.test(value) !== true) {
|
||||||
|
throw new InvalidArgumentError('Must be a valid URL (starting with http:// or https://).')
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askConfirmation () {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
prompt.start()
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
properties: {
|
||||||
|
confirm: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'These URLs can be updated, but please check your backups first (bugs happen).' +
|
||||||
|
' Notice PeerTube must have been stopped when your ran this script.' +
|
||||||
|
' Can we update these URLs? (y/n)',
|
||||||
|
default: 'n',
|
||||||
|
validator: /y[es]*|n[o]?/,
|
||||||
|
warning: 'Must respond yes or no',
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.get(schema, function (err, result) {
|
||||||
|
if (err) return rej(err)
|
||||||
|
|
||||||
|
return res(result.confirm?.match(/y/) !== null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -472,6 +472,24 @@ docker compose exec -u peertube peertube npm run create-move-video-storage-job -
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### Update object storage URLs
|
||||||
|
|
||||||
|
**PeerTube >= 6.2**
|
||||||
|
|
||||||
|
Use this script after you migrated to another object storage provider so PeerTube updates its internal object URLs.
|
||||||
|
|
||||||
|
::: code-group
|
||||||
|
|
||||||
|
```bash [Classic installation]
|
||||||
|
cd /var/www/peertube/peertube-latest
|
||||||
|
sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run update-object-storage-url -- --from 'https://region.old-s3-provider.example.com' --to 'https://region.new-s3-provider.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash [Docker]
|
||||||
|
cd /var/www/peertube-docker
|
||||||
|
docker compose exec -u peertube peertube npm run update-object-storage-url -- --from 'https://region.old-s3-provider.example.com' --to 'https://region.new-s3-provider.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
### Generate storyboard
|
### Generate storyboard
|
||||||
|
|
||||||
**PeerTube >= 6.0**
|
**PeerTube >= 6.0**
|
||||||
|
|
Loading…
Reference in New Issue