import Bluebird from 'bluebird'
import { remove } from 'fs-extra'
import { maxBy, minBy } from 'lodash'
-import { join } from 'path'
import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import {
+ AfterCreate,
+ AfterDestroy,
+ AfterUpdate,
AllowNull,
BeforeDestroy,
BelongsTo,
Table,
UpdatedAt
} from 'sequelize-typescript'
-import { buildNSFWFilter } from '@server/helpers/express-utils'
import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
+import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { LiveManager } from '@server/lib/live/live-manager'
-import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
-import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths'
+import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
+import { tracer } from '@server/lib/opentelemetry/tracing'
+import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
+import { Hooks } from '@server/lib/plugins/hooks'
import { VideoPathManager } from '@server/lib/video-path-manager'
+import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
import { getServerActor } from '@server/models/application/application'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
-import { ffprobePromise, getAudioStream, uuidToShort } from '@shared/extra-utils'
+import { uuidToShort } from '@shared/extra-utils'
+import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg'
import {
ResultList,
ThumbnailType,
import { AttributesOnly } from '@shared/typescript-utils'
import { peertubeTruncate } from '../../helpers/core-utils'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { exists, isBooleanValid } from '../../helpers/custom-validators/misc'
+import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
import {
isVideoDescriptionValid,
isVideoDurationValid,
isVideoStateValid,
isVideoSupportValid
} from '../../helpers/custom-validators/videos'
-import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
-import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
+import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
import { sendDeleteVideo } from '../../lib/activitypub/send'
import {
MChannel,
import { ServerModel } from '../server/server'
import { TrackerModel } from '../server/tracker'
import { VideoTrackerModel } from '../server/video-tracker'
-import { setAsUpdated } from '../shared'
+import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
import { UserModel } from '../user/user'
import { UserVideoHistoryModel } from '../user/user-video-history'
-import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
+import { VideoViewModel } from '../view/video-view'
import {
videoFilesModelToFormattedJSON,
VideoFormattingJSONOptions,
videoModelToFormattedJSON
} from './formatter/video-format-utils'
import { ScheduleVideoUpdateModel } from './schedule-video-update'
-import { VideoModelGetQueryBuilder } from './sql/video-model-get-query-builder'
-import { BuildVideosListQueryOptions, DisplayOnlyForFollowerOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder'
-import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder'
+import {
+ BuildVideosListQueryOptions,
+ DisplayOnlyForFollowerOptions,
+ VideoModelGetQueryBuilder,
+ VideosIdListQueryBuilder,
+ VideosModelListQueryBuilder
+} from './sql/video'
import { TagModel } from './tag'
import { ThumbnailModel } from './thumbnail'
import { VideoBlacklistModel } from './video-blacklist'
import { VideoLiveModel } from './video-live'
import { VideoPlaylistElementModel } from './video-playlist-element'
import { VideoShareModel } from './video-share'
+import { VideoSourceModel } from './video-source'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
import { VideoTagModel } from './video-tag'
-import { VideoViewModel } from './video-view'
export enum ScopeNames {
FOR_API = 'FOR_API',
required: false
},
{
- model: ActorImageModel.unscoped(),
- as: 'Avatar',
+ model: ActorImageModel,
+ as: 'Avatars',
required: false
}
]
required: false
},
{
- model: ActorImageModel.unscoped(),
- as: 'Avatar',
+ model: ActorImageModel,
+ as: 'Avatars',
required: false
}
]
})
VideoPlaylistElements: VideoPlaylistElementModel[]
+ @HasOne(() => VideoSourceModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: true
+ },
+ onDelete: 'CASCADE'
+ })
+ VideoSource: VideoSourceModel
+
@HasMany(() => VideoAbuseModel, {
foreignKey: {
name: 'videoId',
name: 'videoId',
allowNull: false
},
+ hooks: true,
onDelete: 'cascade'
})
VideoLive: VideoLiveModel
})
VideoJobInfo: VideoJobInfoModel
+ @AfterCreate
+ static notifyCreate (video: MVideo) {
+ InternalEventEmitter.Instance.emit('video-created', { video })
+ }
+
+ @AfterUpdate
+ static notifyUpdate (video: MVideo) {
+ InternalEventEmitter.Instance.emit('video-updated', { video })
+ }
+
+ @AfterDestroy
+ static notifyDestroy (video: MVideo) {
+ InternalEventEmitter.Instance.emit('video-deleted', { video })
+ }
+
@BeforeDestroy
- static async sendDelete (instance: MVideoAccountLight, options) {
+ static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
if (!instance.isOwned()) return undefined
// Lazy load channels
// Remove physical files and torrents
instance.VideoFiles.forEach(file => {
- tasks.push(instance.removeWebTorrentFileAndTorrent(file))
+ tasks.push(instance.removeWebTorrentFile(file))
})
// Remove playlists file
// Do not wait video deletion because we could be in a transaction
Promise.all(tasks)
- .catch(err => {
- logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
- })
+ .then(() => logger.info('Removed files of video %s.', instance.url))
+ .catch(err => logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }))
return undefined
}
logger.info('Stopping live of video %s after video deletion.', instance.uuid)
- LiveManager.Instance.stopSessionOf(instance.id)
+ LiveManager.Instance.stopSessionOf(instance.uuid, null)
}
@BeforeDestroy
const total = totalVideos + totalVideoShares
return {
data: rows,
- total: total
+ total
}
})
}
}) {
const { accountId, channelId, start, count, sort, search, isLive } = options
- function buildBaseQuery (): FindOptions {
+ function buildBaseQuery (forCount: boolean): FindOptions {
const where: WhereOptions = {}
if (search) {
order: getVideoSort(sort),
include: [
{
- model: VideoChannelModel,
+ model: forCount
+ ? VideoChannelModel.unscoped()
+ : VideoChannelModel,
required: true,
where: channelWhere,
include: [
{
- model: AccountModel,
+ model: forCount
+ ? AccountModel.unscoped()
+ : AccountModel,
where: {
id: accountId
},
return baseQuery
}
- const countQuery = buildBaseQuery()
- const findQuery = buildBaseQuery()
+ const countQuery = buildBaseQuery(true)
+ const findQuery = buildBaseQuery(false)
const findScopes: (string | ScopeOptions)[] = [
ScopeNames.WITH_SCHEDULED_UPDATE,
countVideos?: boolean
search?: string
+
+ excludeAlreadyWatched?: boolean
}) {
VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
'historyOfUser',
'hasHLSFiles',
'hasWebtorrentFiles',
- 'search'
+ 'search',
+ 'excludeAlreadyWatched'
]),
serverAccountIdForBlock: serverActor.Account.id,
durationMin?: number // seconds
durationMax?: number // seconds
uuids?: string[]
+
+ excludeAlreadyWatched?: boolean
}) {
VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
'hasWebtorrentFiles',
'uuids',
'search',
- 'displayOnlyForFollower'
+ 'displayOnlyForFollower',
+ 'excludeAlreadyWatched'
]),
serverAccountIdForBlock: serverActor.Account.id
}
return VideoModel.getAvailableForApi(queryOptions)
}
- static countLocalLives () {
- const options = {
+ static countLives (options: {
+ remote: boolean
+ mode: 'published' | 'not-ended'
+ }) {
+ const query = {
where: {
- remote: false,
+ remote: options.remote,
isLive: true,
- state: {
- [Op.ne]: VideoState.LIVE_ENDED
- }
+ state: options.mode === 'not-ended'
+ ? { [Op.ne]: VideoState.LIVE_ENDED }
+ : { [Op.eq]: VideoState.PUBLISHED }
}
}
- return VideoModel.count(options)
+ return VideoModel.count(query)
}
static countVideosUploadedByUserSince (userId: number, since: Date) {
return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
}
- static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
+ static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
- return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
+ return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
}
static loadForGetAPI (parameters: {
}
static async getStats () {
- const totalLocalVideos = await VideoModel.count({
- where: {
- remote: false
- }
- })
+ const serverActor = await getServerActor()
let totalLocalVideoViews = await VideoModel.sum('views', {
where: {
// Sequelize could return null...
if (!totalLocalVideoViews) totalLocalVideoViews = 0
- const serverActor = await getServerActor()
-
- const { total: totalVideos } = await VideoModel.listForApi({
+ const baseOptions = {
start: 0,
count: 0,
sort: '-publishedAt',
- nsfw: buildNSFWFilter(),
+ nsfw: null,
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
}
+ }
+
+ const { total: totalLocalVideos } = await VideoModel.listForApi({
+ ...baseOptions,
+
+ isLocal: true
})
+ const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
+
return {
totalLocalVideos,
totalLocalVideoViews,
})
}
- static updateRatesOf (videoId: number, type: VideoRateType, t: Transaction) {
+ static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
+ const field = type === 'like'
+ ? 'likes'
+ : 'dislikes'
+
+ const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
+
+ return AccountVideoRateModel.sequelize.query(rawQuery, {
+ transaction: t,
+ replacements: { videoId, rateType: type, count },
+ type: QueryTypes.UPDATE
+ })
+ }
+
+ static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
const field = type === 'like'
? 'likes'
: 'dislikes'
const query = 'SELECT 1 FROM "videoShare" ' +
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
+ 'UNION ' +
+ 'SELECT 1 FROM "video" ' +
+ 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
+ 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
+ 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "account"."actorId" ' +
+ 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "video"."id" = $videoId ' +
'LIMIT 1'
const options = {
options: BuildVideosListQueryOptions,
countVideos = true
): Promise<ResultList<VideoModel>> {
+ const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
+
function getCount () {
if (countVideos !== true) return Promise.resolve(undefined)
const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
+ span.end()
+
return {
data: rows,
total: count
}
getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
- // We first transcode to WebTorrent format, so try this array first
- if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
- const file = fun(this.VideoFiles, file => file.resolution)
+ const files = this.getAllFiles()
+ const file = fun(files, file => file.resolution)
+ if (!file) return undefined
+ if (file.videoId) {
return Object.assign(file, { Video: this })
}
- // No webtorrent files, try with streaming playlist files
- if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
+ if (file.videoStreamingPlaylistId) {
const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
- const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
}
- return undefined
+ throw new Error('File is not associated to a video of a playlist')
}
getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
const thumbnail = this.getMiniature()
if (!thumbnail) return null
- return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
+ return thumbnail.getLocalStaticPath()
}
getPreviewStaticPath () {
const preview = this.getPreview()
if (!preview) return null
- // We use a local cache, so specify our cache endpoint instead of potential remote URL
- return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
+ return preview.getLocalStaticPath()
}
toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
return videoModelToFormattedDetailsJSON(this)
}
- getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
+ getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
+ return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
+ }
+
+ getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
+ let acc: VideoFile[] = []
+
+ for (const p of this.VideoStreamingPlaylists) {
+ acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
+ }
+
+ return acc
+ }
+
+ getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
let files: VideoFile[] = []
if (Array.isArray(this.VideoFiles)) {
- const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
- files = files.concat(result)
+ files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
}
- for (const p of (this.VideoStreamingPlaylists || [])) {
- const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
- files = files.concat(result)
+ if (Array.isArray(this.VideoStreamingPlaylists)) {
+ files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
}
return files
}
- toActivityPubObject (this: MVideoAP): VideoObject {
- return videoModelToActivityPubObject(this)
+ toActivityPubObject (this: MVideoAP): Promise<VideoObject> {
+ return Hooks.wrapObject(
+ videoModelToActivityPubObject(this),
+ 'filter:activity-pub.video.json-ld.build.result',
+ { video: this }
+ )
}
getTruncatedDescription () {
return peertubeTruncate(this.description, { length: maxLength })
}
- getMaxQualityFileInfo () {
+ getAllFiles () {
+ let files: MVideoFile[] = []
+
+ if (Array.isArray(this.VideoFiles)) {
+ files = files.concat(this.VideoFiles)
+ }
+
+ if (Array.isArray(this.VideoStreamingPlaylists)) {
+ for (const p of this.VideoStreamingPlaylists) {
+ if (Array.isArray(p.VideoFiles)) {
+ files = files.concat(p.VideoFiles)
+ }
+ }
+ }
+
+ return files
+ }
+
+ probeMaxQualityFile () {
const file = this.getMaxQualityFile()
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
const probe = await ffprobePromise(originalFilePath)
const { audioStream } = await getAudioStream(originalFilePath, probe)
+ const hasAudio = await hasAudioStream(originalFilePath, probe)
+ const fps = await getVideoStreamFPS(originalFilePath, probe)
return {
audioStream,
+ hasAudio,
+ fps,
- ...await getVideoFileResolution(originalFilePath, probe)
+ ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
}
})
}
const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
if (!playlist) return undefined
- playlist.Video = this
-
- return playlist
+ return playlist.withVideo(this)
}
setHLSPlaylist (playlist: MStreamingPlaylist) {
.concat(toAdd)
}
- removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
+ removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) {
const filePath = isRedundancy
? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
: VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
}
}
+ async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
+ const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
+ await videoFile.removeTorrent()
+ await remove(filePath)
+
+ const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
+ await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
+
+ if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
+ await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
+ await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
+ }
+ }
+
+ async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
+ const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
+ await remove(filePath)
+
+ if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
+ await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
+ }
+ }
+
isOutdated () {
if (this.isOwned()) return false
}
setAsRefreshed (transaction?: Transaction) {
- return setAsUpdated('video', this.id, transaction)
+ return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
}
- requiresAuth () {
- return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
- }
+ // ---------------------------------------------------------------------------
- setPrivacy (newPrivacy: VideoPrivacy) {
- if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
- this.publishedAt = new Date()
+ requiresAuth (options: {
+ urlParamId: string
+ checkBlacklist: boolean
+ }) {
+ const { urlParamId, checkBlacklist } = options
+
+ if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
+ return true
+ }
+
+ if (this.privacy === VideoPrivacy.UNLISTED) {
+ if (urlParamId && !isUUIDValid(urlParamId)) return true
+
+ return false
}
- this.privacy = newPrivacy
+ if (checkBlacklist && this.VideoBlacklist) return true
+
+ if (this.privacy !== VideoPrivacy.PUBLIC) {
+ throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
+ }
+
+ return false
}
- isConfidential () {
- return this.privacy === VideoPrivacy.PRIVATE ||
- this.privacy === VideoPrivacy.UNLISTED ||
- this.privacy === VideoPrivacy.INTERNAL
+ hasPrivateStaticPath () {
+ return isVideoInPrivateDirectory(this.privacy)
}
+ // ---------------------------------------------------------------------------
+
async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
if (this.state === newState) throw new Error('Cannot use same state ' + newState)
}
getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
+ if (!this.duration) throw new Error(`Cannot get bandwidth bits because video ${this.url} has duration of 0`)
+
return Math.ceil((videoFile.size * 8) / this.duration)
}