1
0
Fork 0
peertube/server/core/models/video/video-file.ts
Chocobozzz 50b067f9cd
Separate player in dedicated build
* Separate player in a dedicated build, that we can control using vite.
   We had too many issues with Angular build system and we can now
   have the same build between the embed and the client. We can also
   embed SVG directly in the CSS
 * Upgrade p2p-media-loader to v2
 * Update internal infohashes to reflect this p2p-media-loader protocol
   change (they are updated at PeerTube startup)
 * Minimum required iOS version is now v14
2025-02-10 15:38:20 +01:00

686 lines
18 KiB
TypeScript

import {
ActivityVideoUrlObject,
FileStorage,
type FileStorageType,
VideoFileFormatFlag,
type VideoFileFormatFlagType,
VideoFileStream,
type VideoFileStreamType,
VideoResolution
} from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { extractVideo } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js'
import { buildRemoteUrl } from '@server/lib/activitypub/url.js'
import {
getHLSPrivateFileUrl,
getObjectStoragePublicFileUrl,
getWebVideoPrivateFileUrl
} from '@server/lib/object-storage/index.js'
import { getFSTorrentFilePath } from '@server/lib/paths.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
import { MStreamingPlaylistVideo, MVideo, MVideoWithHost, isStreamingPlaylist } from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm'
import memoizee from 'memoizee'
import { join } from 'path'
import { FindOptions, Op, Transaction, WhereOptions } from 'sequelize'
import {
AllowNull,
BelongsTo,
Column,
CreatedAt,
DataType,
Default,
DefaultScope,
ForeignKey,
Is, Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
import validator from 'validator'
import {
isVideoFPSResolutionValid,
isVideoFileExtnameValid,
isVideoFileInfoHashValid,
isVideoFileResolutionValid,
isVideoFileSizeValid
} from '../../helpers/custom-validators/videos.js'
import {
DOWNLOAD_PATHS,
LAZY_STATIC_PATHS,
MEMOIZE_LENGTH,
MEMOIZE_TTL,
STATIC_PATHS,
WEBSERVER
} from '../../initializers/constants.js'
import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file.js'
import { SequelizeModel, doesExist, parseAggregateResult, throwIfNotValid } from '../shared/index.js'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
import { VideoModel } from './video.js'
export enum ScopeNames {
WITH_VIDEO = 'WITH_VIDEO',
WITH_METADATA = 'WITH_METADATA',
WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST'
}
@DefaultScope(() => ({
attributes: {
exclude: [ 'metadata' ]
}
}))
@Scopes(() => ({
[ScopeNames.WITH_VIDEO]: {
include: [
{
model: VideoModel.unscoped(),
required: true
}
]
},
[ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (options: { whereVideo?: WhereOptions } = {}) => {
return {
include: [
{
model: VideoModel.unscoped(),
required: false,
where: options.whereVideo
},
{
model: VideoStreamingPlaylistModel.unscoped(),
required: false,
include: [
{
model: VideoModel.unscoped(),
required: true,
where: options.whereVideo
}
]
}
]
}
},
[ScopeNames.WITH_METADATA]: {
attributes: {
include: [ 'metadata' ]
}
}
}))
@Table({
tableName: 'videoFile',
indexes: [
{
fields: [ 'infoHash' ]
},
{
fields: [ 'torrentFilename' ],
unique: true
},
{
fields: [ 'filename' ],
unique: true
},
{
fields: [ 'videoId', 'resolution', 'fps' ],
unique: true,
where: {
videoId: {
[Op.ne]: null
}
}
},
{
fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ],
unique: true,
where: {
videoStreamingPlaylistId: {
[Op.ne]: null
}
}
}
]
})
export class VideoFileModel extends SequelizeModel<VideoFileModel> {
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
@AllowNull(false)
@Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
@Column
resolution: number
@AllowNull(true)
@Column
width: number
@AllowNull(true)
@Column
height: number
@AllowNull(false)
@Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
@Column(DataType.BIGINT)
size: number
@AllowNull(false)
@Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
@Column
extname: string
@AllowNull(true)
@Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
@Column
infoHash: string
@AllowNull(false)
@Default(-1)
@Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
@Column
fps: number
@AllowNull(false)
@Column
formatFlags: VideoFileFormatFlagType
@AllowNull(false)
@Column
streams: VideoFileStreamType
@AllowNull(true)
@Column(DataType.JSONB)
metadata: any
@AllowNull(true)
@Column
metadataUrl: string
// Could be null for remote files
@AllowNull(true)
@Column
fileUrl: string
// Could be null for live files
@AllowNull(true)
@Column
filename: string
// Could be null for remote files
@AllowNull(true)
@Column
torrentUrl: string
// Could be null for live files
@AllowNull(true)
@Column
torrentFilename: string
@ForeignKey(() => VideoModel)
@Column
videoId: number
@AllowNull(false)
@Default(FileStorage.FILE_SYSTEM)
@Column
storage: FileStorageType
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: true
},
onDelete: 'CASCADE'
})
Video: Awaited<VideoModel>
@ForeignKey(() => VideoStreamingPlaylistModel)
@Column
videoStreamingPlaylistId: number
@BelongsTo(() => VideoStreamingPlaylistModel, {
foreignKey: {
allowNull: true
},
onDelete: 'CASCADE'
})
VideoStreamingPlaylist: Awaited<VideoStreamingPlaylistModel>
static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist.bind(VideoFileModel), {
promise: true,
max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
})
static doesInfohashExist (infoHash: string) {
const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
return doesExist({ sequelize: this.sequelize, query, bind: { infoHash } })
}
static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
return !!videoFile
}
static async doesOwnedTorrentFileExist (filename: string) {
const query = 'SELECT 1 FROM "videoFile" ' +
'LEFT JOIN "video" "webvideo" ON "webvideo"."id" = "videoFile"."videoId" AND "webvideo"."remote" IS FALSE ' +
'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webvideo"."id" IS NOT NULL) LIMIT 1'
return doesExist({ sequelize: this.sequelize, query, bind: { filename } })
}
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`
return doesExist({ sequelize: this.sequelize, query, bind: { filename, storage } })
}
static loadByFilename (filename: string) {
const query = {
where: {
filename
}
}
return VideoFileModel.findOne(query)
}
static loadWithVideoByFilename (filename: string): Promise<MVideoFileVideo | MVideoFileStreamingPlaylistVideo> {
const query = {
where: {
filename
}
}
return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
}
static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
const query = {
where: {
torrentFilename: filename
}
}
return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
}
static load (id: number): Promise<MVideoFile> {
return VideoFileModel.findByPk(id)
}
static loadWithMetadata (id: number) {
return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk<MVideoFile>(id)
}
static loadWithVideo (id: number, transaction?: Transaction) {
return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk<MVideoFileVideo>(id, { transaction })
}
static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
const whereVideo = validator.default.isUUID(videoIdOrUUID + '')
? { uuid: videoIdOrUUID }
: { id: videoIdOrUUID }
const options = {
where: {
id
}
}
return VideoFileModel.scope({ method: [ ScopeNames.WITH_VIDEO_OR_PLAYLIST, { whereVideo } ] })
.findOne(options)
.then(file => {
if (!file) return null
// We used `required: false` so check we have at least a video or a streaming playlist
if (!file.Video && !file.VideoStreamingPlaylist) return null
return file
})
}
static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
const query = {
include: [
{
model: VideoStreamingPlaylistModel.unscoped(),
required: true,
where: {
id: streamingPlaylistId
}
}
],
transaction
}
return VideoFileModel.findAll<MVideoFile>(query)
}
static getStats () {
const webVideoFilesQuery: FindOptions = {
include: [
{
attributes: [],
required: true,
model: VideoModel.unscoped(),
where: {
remote: false
}
}
]
}
const hlsFilesQuery: FindOptions = {
include: [
{
attributes: [],
required: true,
model: VideoStreamingPlaylistModel.unscoped(),
include: [
{
attributes: [],
model: VideoModel.unscoped(),
required: true,
where: {
remote: false
}
}
]
}
]
}
return Promise.all([
VideoFileModel.aggregate('size', 'SUM', webVideoFilesQuery),
VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
]).then(([ webVideoResult, hlsResult ]) => ({
totalLocalVideoFilesSize: parseAggregateResult(webVideoResult) + parseAggregateResult(hlsResult)
}))
}
// Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
static async customUpsert (
videoFile: MVideoFile,
mode: 'streaming-playlist' | 'video',
transaction: Transaction
) {
const baseFind = {
fps: videoFile.fps,
resolution: videoFile.resolution,
transaction
}
const element = mode === 'streaming-playlist'
? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId })
: await VideoFileModel.loadWebVideoFile({ ...baseFind, videoId: videoFile.videoId })
if (!element) return videoFile.save({ transaction })
for (const k of Object.keys(videoFile.toJSON())) {
element.set(k, videoFile[k])
}
return element.save({ transaction })
}
static async loadWebVideoFile (options: {
videoId: number
fps: number
resolution: number
transaction?: Transaction
}) {
const where = {
fps: options.fps,
resolution: options.resolution,
videoId: options.videoId
}
return VideoFileModel.findOne({ where, transaction: options.transaction })
}
static async loadHLSFile (options: {
playlistId: number
fps: number
resolution: number
transaction?: Transaction
}) {
const where = {
fps: options.fps,
resolution: options.resolution,
videoStreamingPlaylistId: options.playlistId
}
return VideoFileModel.findOne({ where, transaction: options.transaction })
}
static removeHLSFilesOfStreamingPlaylistId (videoStreamingPlaylistId: number) {
const options = {
where: { videoStreamingPlaylistId }
}
return VideoFileModel.destroy(options)
}
hasTorrent () {
return this.infoHash && this.torrentFilename
}
getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
if (this.videoId || (this as MVideoFileVideo).Video) return (this as MVideoFileVideo).Video
return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
}
getVideo (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo {
return extractVideo(this.getVideoOrStreamingPlaylist())
}
// ---------------------------------------------------------------------------
isAudio () {
return this.resolution === VideoResolution.H_NOVIDEO
}
isLive () {
return this.size === -1
}
isHLS () {
return !!this.videoStreamingPlaylistId
}
hasAudio () {
return (this.streams & VideoFileStream.AUDIO) === VideoFileStream.AUDIO
}
hasVideo () {
return (this.streams & VideoFileStream.VIDEO) === VideoFileStream.VIDEO
}
// ---------------------------------------------------------------------------
getObjectStorageUrl (video: MVideo) {
if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
return this.getPrivateObjectStorageUrl(video)
}
return this.getPublicObjectStorageUrl()
}
private getPrivateObjectStorageUrl (video: MVideo) {
if (this.isHLS()) {
return getHLSPrivateFileUrl(video, this.filename)
}
return getWebVideoPrivateFileUrl(this.filename)
}
private getPublicObjectStorageUrl () {
if (this.isHLS()) {
return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
}
// ---------------------------------------------------------------------------
getFileUrl (video: MVideo) {
if (video.isOwned()) {
if (this.storage === FileStorage.OBJECT_STORAGE) {
return this.getObjectStorageUrl(video)
}
return WEBSERVER.URL + this.getFileStaticPath(video)
}
return this.fileUrl
}
// ---------------------------------------------------------------------------
getFileStaticPath (video: MVideo) {
if (this.isHLS()) return this.getHLSFileStaticPath(video)
return this.getWebVideoFileStaticPath(video)
}
private getWebVideoFileStaticPath (video: MVideo) {
if (isVideoInPrivateDirectory(video.privacy)) {
return join(STATIC_PATHS.PRIVATE_WEB_VIDEOS, this.filename)
}
return join(STATIC_PATHS.WEB_VIDEOS, this.filename)
}
private getHLSFileStaticPath (video: MVideo) {
if (isVideoInPrivateDirectory(video.privacy)) {
return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.filename)
}
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename)
}
// ---------------------------------------------------------------------------
getFileDownloadUrl (video: MVideoWithHost) {
const path = this.isHLS()
? join(DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
: join(DOWNLOAD_PATHS.WEB_VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
if (video.isOwned()) return WEBSERVER.URL + path
// FIXME: don't guess remote URL
return buildRemoteUrl(video, path)
}
getRemoteTorrentUrl (video: MVideo) {
if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`)
return this.torrentUrl
}
// We proxify torrent requests so use a local URL
getTorrentUrl () {
if (!this.torrentFilename) return null
return WEBSERVER.URL + this.getTorrentStaticPath()
}
getTorrentStaticPath () {
if (!this.torrentFilename) return null
return join(LAZY_STATIC_PATHS.TORRENTS, this.torrentFilename)
}
getTorrentDownloadUrl () {
if (!this.torrentFilename) return null
return WEBSERVER.URL + join(DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
}
removeTorrent () {
if (!this.torrentFilename) return null
const torrentPath = getFSTorrentFilePath(this)
return remove(torrentPath)
.catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
}
hasSameUniqueKeysThan (other: MVideoFile) {
return this.fps === other.fps &&
this.resolution === other.resolution &&
(
(this.videoId !== null && this.videoId === other.videoId) ||
(this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)
)
}
withVideoOrPlaylist (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
if (isStreamingPlaylist(videoOrPlaylist)) return Object.assign(this, { VideoStreamingPlaylist: videoOrPlaylist })
return Object.assign(this, { Video: videoOrPlaylist })
}
// ---------------------------------------------------------------------------
toActivityPubObject (this: MVideoFile, video: MVideo): ActivityVideoUrlObject {
const mimeType = getVideoFileMimeType(this.extname, false)
const attachment: ActivityVideoUrlObject['attachment'] = []
if (this.hasAudio()) {
attachment.push({
type: 'PropertyValue',
name: 'ffprobe_codec_type',
value: 'audio'
})
}
if (this.hasVideo()) {
attachment.push({
type: 'PropertyValue',
name: 'ffprobe_codec_type',
value: 'video'
})
}
if (this.formatFlags & VideoFileFormatFlag.FRAGMENTED) {
attachment.push({
type: 'PropertyValue',
name: 'peertube_format_flag',
value: 'fragmented'
})
}
if (this.formatFlags & VideoFileFormatFlag.WEB_VIDEO) {
attachment.push({
type: 'PropertyValue',
name: 'peertube_format_flag',
value: 'web-video'
})
}
return {
type: 'Link',
mediaType: mimeType as ActivityVideoUrlObject['mediaType'],
href: this.getFileUrl(video),
height: this.height || this.resolution,
width: this.width,
size: this.size,
fps: this.fps,
attachment
}
}
}