PeerTube/server/core/lib/user-import-export/exporters/videos-exporter.ts

344 lines
12 KiB
TypeScript

import { VideoModel } from '@server/models/video/video.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import { ExportResult, AbstractUserExporter } from './abstract-user-exporter.js'
import {
MStreamingPlaylistFiles,
MThumbnail, MVideo, MVideoAP, MVideoCaption,
MVideoCaptionLanguageUrl,
MVideoChapter,
MVideoFile,
MVideoFullLight, MVideoLiveWithSetting,
MVideoPassword
} from '@server/types/models/index.js'
import { logger } from '@server/helpers/logger.js'
import { ActivityCreate, VideoExportJSON, VideoObject, VideoPrivacy, FileStorage } from '@peertube/peertube-models'
import Bluebird from 'bluebird'
import { getHLSFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js'
import { createReadStream } from 'fs'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { extname, join } from 'path'
import { Readable } from 'stream'
import { getAudience, audiencify } from '@server/lib/activitypub/audience.js'
import { buildCreateActivity } from '@server/lib/activitypub/send/send-create.js'
import { pick } from '@peertube/peertube-core-utils'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js'
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
constructor (private readonly options: ConstructorParameters<typeof AbstractUserExporter<VideoExportJSON>>[0] & {
withVideoFiles: boolean
}) {
super(options)
}
async export () {
const videosJSON: VideoExportJSON['videos'] = []
const activityPubOutbox: ActivityCreate<VideoObject>[] = []
let staticFiles: ExportResult<VideoExportJSON>['staticFiles'] = []
const channels = await VideoChannelModel.listAllByAccount(this.user.Account.id)
for (const channel of channels) {
const videoIds = await VideoModel.getAllIdsFromChannel(channel, USER_EXPORT_MAX_ITEMS)
await Bluebird.map(videoIds, async id => {
try {
const exported = await this.exportVideo(id)
videosJSON.push(exported.json)
staticFiles = staticFiles.concat(exported.staticFiles)
activityPubOutbox.push(exported.activityPubOutbox)
} catch (err) {
logger.warn('Cannot export video %d.', id, { err })
}
}, { concurrency: 10 })
}
return {
json: { videos: videosJSON },
activityPubOutbox,
staticFiles
}
}
private async exportVideo (videoId: number) {
const [ video, captions, source, chapters ] = await Promise.all([
VideoModel.loadFull(videoId),
VideoCaptionModel.listVideoCaptions(videoId),
VideoSourceModel.loadLatest(videoId),
VideoChapterModel.listChaptersOfVideo(videoId)
])
const passwords = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
? (await VideoPasswordModel.listPasswords({ videoId, start: 0, count: undefined, sort: 'createdAt' })).data
: []
const live = video.isLive
? await VideoLiveModel.loadByVideoIdWithSettings(videoId)
: undefined;
// We already have captions, so we can set it to the video object
(video as any).VideoCaptions = captions
// Then fetch more attributes for AP serialization
const videoAP = await video.lightAPToFullAP(undefined)
const { relativePathsFromJSON, staticFiles } = this.exportVideoFiles({ video, captions })
return {
json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }),
staticFiles,
relativePathsFromJSON,
activityPubOutbox: await this.exportVideoAP(videoAP, chapters)
}
}
// ---------------------------------------------------------------------------
private exportVideoJSON (options: {
video: MVideoFullLight
captions: MVideoCaption[]
live: MVideoLiveWithSetting
passwords: MVideoPassword[]
source: MVideoSource
chapters: MVideoChapter[]
archiveFiles: VideoExportJSON['videos'][0]['archiveFiles']
}): VideoExportJSON['videos'][0] {
const { video, captions, live, passwords, source, chapters, archiveFiles } = options
return {
uuid: video.uuid,
createdAt: video.createdAt.toISOString(),
updatedAt: video.updatedAt.toISOString(),
publishedAt: video.publishedAt.toISOString(),
originallyPublishedAt: video.originallyPublishedAt
? video.originallyPublishedAt.toISOString()
: undefined,
name: video.name,
category: video.category,
licence: video.licence,
language: video.language,
tags: video.Tags.map(t => t.name),
privacy: video.privacy,
passwords: passwords.map(p => p.password),
duration: video.duration,
description: video.description,
support: video.support,
isLive: video.isLive,
live: this.exportLiveJSON(video, live),
url: video.url,
thumbnailUrl: video.getMiniature()?.getOriginFileUrl(video) || null,
previewUrl: video.getPreview()?.getOriginFileUrl(video) || null,
views: video.views,
likes: video.likes,
dislikes: video.dislikes,
nsfw: video.nsfw,
commentsEnabled: video.commentsEnabled,
downloadEnabled: video.downloadEnabled,
waitTranscoding: video.waitTranscoding,
state: video.state,
channel: {
name: video.VideoChannel.Actor.preferredUsername
},
captions: this.exportCaptionsJSON(video, captions),
chapters: this.exportChaptersJSON(chapters),
files: this.exportFilesJSON(video, video.VideoFiles),
streamingPlaylists: this.exportStreamingPlaylistsJSON(video, video.VideoStreamingPlaylists),
source: source
? { filename: source.filename }
: null,
archiveFiles
}
}
private exportLiveJSON (video: MVideo, live: MVideoLiveWithSetting) {
if (!video.isLive) return undefined
return {
saveReplay: live.saveReplay,
permanentLive: live.permanentLive,
latencyMode: live.latencyMode,
streamKey: live.streamKey,
replaySettings: live.ReplaySetting
? { privacy: live.ReplaySetting.privacy }
: undefined
}
}
private exportCaptionsJSON (video: MVideo, captions: MVideoCaption[]) {
return captions.map(c => ({
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
language: c.language,
filename: c.filename,
fileUrl: c.getFileUrl(video)
}))
}
private exportChaptersJSON (chapters: MVideoChapter[]) {
return chapters.map(c => ({
timecode: c.timecode,
title: c.title
}))
}
private exportFilesJSON (video: MVideo, files: MVideoFile[]) {
return files.map(f => ({
resolution: f.resolution,
size: f.size,
fps: f.fps,
torrentUrl: f.getTorrentUrl(),
fileUrl: f.getFileUrl(video)
}))
}
private exportStreamingPlaylistsJSON (video: MVideo, streamingPlaylists: MStreamingPlaylistFiles[]) {
return streamingPlaylists.map(p => ({
type: p.type,
playlistUrl: p.getMasterPlaylistUrl(video),
segmentsSha256Url: p.getMasterPlaylistUrl(video),
files: this.exportFilesJSON(video, p.VideoFiles)
}))
}
// ---------------------------------------------------------------------------
private async exportVideoAP (video: MVideoAP, chapters: MVideoChapter[]): Promise<ActivityCreate<VideoObject>> {
const videoFile = video.getMaxQualityFile()
const icon = video.getPreview()
const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
const videoObject = {
...audiencify(await video.toActivityPubObject(), audience),
icon: [
{
...icon.toActivityPubObject(video),
url: join(this.options.relativeStaticDirPath, this.getArchiveThumbnailFilePath(video, icon))
}
],
subtitleLanguage: video.VideoCaptions.map(c => ({
...c.toActivityPubObject(video),
url: join(this.options.relativeStaticDirPath, this.getArchiveCaptionFilePath(video, c))
})),
hasParts: buildChaptersAPHasPart(video, chapters),
attachment: this.options.withVideoFiles && videoFile
? [
{
type: 'Video' as 'Video',
url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, videoFile)),
...pick(videoFile.toActivityPubObject(video), [ 'mediaType', 'height', 'size', 'fps' ])
}
]
: undefined
}
return buildCreateActivity(video.url, video.VideoChannel.Account.Actor, videoObject, audience)
}
// ---------------------------------------------------------------------------
private exportVideoFiles (options: {
video: MVideoFullLight
captions: MVideoCaption[]
}) {
const { video, captions } = options
const staticFiles: ExportResult<VideoExportJSON>['staticFiles'] = []
const relativePathsFromJSON = {
videoFile: null as string,
thumbnail: null as string,
captions: {} as { [ lang: string ]: string }
}
const videoFile = video.getMaxQualityFile()
if (this.options.withVideoFiles && videoFile) {
staticFiles.push({
archivePath: this.getArchiveVideoFilePath(video, videoFile),
createrReadStream: () => this.generateVideoFileReadStream(video, videoFile)
})
relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, this.getArchiveVideoFilePath(video, videoFile))
}
for (const caption of captions) {
staticFiles.push({
archivePath: this.getArchiveCaptionFilePath(video, caption),
createrReadStream: () => Promise.resolve(createReadStream(caption.getFSPath()))
})
relativePathsFromJSON.captions[caption.language] = join(this.relativeStaticDirPath, this.getArchiveCaptionFilePath(video, caption))
}
const thumbnail = video.getPreview() || video.getMiniature()
if (thumbnail) {
staticFiles.push({
archivePath: this.getArchiveThumbnailFilePath(video, thumbnail),
createrReadStream: () => Promise.resolve(createReadStream(thumbnail.getPath()))
})
relativePathsFromJSON.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailFilePath(video, thumbnail))
}
return { staticFiles, relativePathsFromJSON }
}
private async generateVideoFileReadStream (video: MVideoFullLight, videoFile: MVideoFile): Promise<Readable> {
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile))
}
const { stream } = videoFile.isHLS()
? await getHLSFileReadStream({ playlist: video.getHLSPlaylist(), filename: videoFile.filename, rangeHeader: undefined })
: await getWebVideoFileReadStream({ filename: videoFile.filename, rangeHeader: undefined })
return stream
}
private getArchiveVideoFilePath (video: MVideo, videoFile: MVideoFile) {
return join('video-files', video.uuid + extname(videoFile.filename))
}
private getArchiveCaptionFilePath (video: MVideo, caption: MVideoCaptionLanguageUrl) {
return join('captions', video.uuid + '-' + caption.language + extname(caption.filename))
}
private getArchiveThumbnailFilePath (video: MVideo, thumbnail: MThumbnail) {
return join('thumbnails', video.uuid + extname(thumbnail.filename))
}
}