-import * as Bluebird from 'bluebird'
+import Bluebird from 'bluebird'
import { remove } from 'fs-extra'
import { maxBy, minBy } from 'lodash'
import { join } from 'path'
Table,
UpdatedAt
} from 'sequelize-typescript'
-import { setAsUpdated } from '@server/helpers/database-utils'
import { buildNSFWFilter } from '@server/helpers/express-utils'
import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
import { LiveManager } from '@server/lib/live/live-manager'
-import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths'
+import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
+import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths'
+import { VideoPathManager } from '@server/lib/video-path-manager'
import { getServerActor } from '@server/models/application/application'
import { ModelCache } from '@server/models/model-cache'
-import { AttributesOnly } from '@shared/core-utils'
-import { VideoFile } from '@shared/models/videos/video-file.model'
-import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
-import { VideoObject } from '../../../shared/models/activitypub/objects'
-import { Video, VideoDetails, VideoRateType } from '../../../shared/models/videos'
-import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
-import { VideoFilter } from '../../../shared/models/videos/video-query.type'
-import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
+import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
+import { ffprobePromise, getAudioStream, uuidToShort } from '@shared/extra-utils'
+import {
+ ResultList,
+ ThumbnailType,
+ UserRight,
+ Video,
+ VideoDetails,
+ VideoFile,
+ VideoInclude,
+ VideoObject,
+ VideoPrivacy,
+ VideoRateType,
+ VideoState,
+ VideoStorage,
+ VideoStreamingPlaylistType
+} from '@shared/models'
+import { AttributesOnly } from '@shared/typescript-utils'
import { peertubeTruncate } from '../../helpers/core-utils'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { isBooleanValid } from '../../helpers/custom-validators/misc'
+import { exists, isBooleanValid } from '../../helpers/custom-validators/misc'
import {
- isVideoCategoryValid,
isVideoDescriptionValid,
isVideoDurationValid,
- isVideoLanguageValid,
- isVideoLicenceValid,
isVideoNameValid,
isVideoPrivacyValid,
isVideoStateValid,
import { ServerModel } from '../server/server'
import { TrackerModel } from '../server/tracker'
import { VideoTrackerModel } from '../server/video-tracker'
+import { setAsUpdated } from '../shared'
import { UserModel } from '../user/user'
import { UserVideoHistoryModel } from '../user/user-video-history'
import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
videoModelToFormattedJSON
} from './formatter/video-format-utils'
import { ScheduleVideoUpdateModel } from './schedule-video-update'
-import { VideosModelGetQueryBuilder } from './sql/video-model-get-query-builder'
-import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder'
+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 { TagModel } from './tag'
import { ThumbnailModel } from './thumbnail'
import { VideoCommentModel } from './video-comment'
import { VideoFileModel } from './video-file'
import { VideoImportModel } from './video-import'
+import { VideoJobInfoModel } from './video-job-info'
import { VideoLiveModel } from './video-live'
import { VideoPlaylistElementModel } from './video-playlist-element'
import { VideoShareModel } from './video-share'
withAccountBlockerIds?: number[]
}
-export type AvailableForListIDsOptions = {
- serverAccountId: number
- followerActorId: number
- includeLocalVideos: boolean
-
- attributesType?: 'none' | 'id' | 'all'
-
- filter?: VideoFilter
- categoryOneOf?: number[]
- nsfw?: boolean
- licenceOneOf?: number[]
- languageOneOf?: string[]
- tagsOneOf?: string[]
- tagsAllOf?: string[]
-
- withFiles?: boolean
-
- accountId?: number
- videoChannelId?: number
-
- videoPlaylistId?: number
-
- trendingDays?: number
- user?: MUserAccountId
- historyOfUser?: MUserId
-
- baseWhere?: WhereOptions[]
-}
-
@Scopes(() => ({
[ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
attributes: [ 'id', 'url', 'uuid', 'remote' ]
@AllowNull(true)
@Default(null)
- @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category', true))
@Column
category: number
@AllowNull(true)
@Default(null)
- @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence', true))
@Column
licence: number
@AllowNull(true)
@Default(null)
- @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language', true))
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
language: string
foreignKey: {
allowNull: true
},
- hooks: true
+ onDelete: 'cascade'
})
VideoChannel: VideoChannelModel
})
VideoCaptions: VideoCaptionModel[]
+ @HasOne(() => VideoJobInfoModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ VideoJobInfo: VideoJobInfoModel
+
@BeforeDestroy
static async sendDelete (instance: MVideoAccountLight, options) {
if (!instance.isOwned()) return undefined
// Remove physical files and torrents
instance.VideoFiles.forEach(file => {
- tasks.push(instance.removeFile(file))
- tasks.push(file.removeTorrent())
+ tasks.push(instance.removeWebTorrentFileAndTorrent(file))
})
// Remove playlists file
await Promise.all(tasks)
}
- static listLocal (): Promise<MVideo[]> {
+ static listLocalIds (): Promise<number[]> {
const query = {
+ attributes: [ 'id' ],
+ raw: true,
where: {
remote: false
}
}
return VideoModel.findAll(query)
+ .then(rows => rows.map(r => r.id))
}
static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
distinct: true,
offset: start,
limit: count,
- order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
+ order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
where: {
id: {
[Op.in]: Sequelize.literal('(' + rawQuery + ')')
start: number
count: number
sort: string
+
+ channelId?: number
isLive?: boolean
search?: string
}) {
- const { accountId, start, count, sort, search, isLive } = options
+ const { accountId, channelId, start, count, sort, search, isLive } = options
function buildBaseQuery (): FindOptions {
const where: WhereOptions = {}
}
}
- if (isLive) {
+ if (exists(isLive)) {
where.isLive = isLive
}
+ const channelWhere = channelId
+ ? { id: channelId }
+ : {}
+
const baseQuery = {
offset: start,
limit: count,
{
model: VideoChannelModel,
required: true,
+ where: channelWhere,
include: [
{
model: AccountModel,
sort: string
nsfw: boolean
- filter?: VideoFilter
isLive?: boolean
+ isLocal?: boolean
+ include?: VideoInclude
- includeLocalVideos: boolean
- withFiles: boolean
+ hasFiles?: boolean // default false
+ hasWebtorrentFiles?: boolean
+ hasHLSFiles?: boolean
categoryOneOf?: number[]
licenceOneOf?: number[]
languageOneOf?: string[]
tagsOneOf?: string[]
tagsAllOf?: string[]
+ privacyOneOf?: VideoPrivacy[]
accountId?: number
videoChannelId?: number
- followerActorId?: number
+ displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
videoPlaylistId?: number
search?: string
}) {
- if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
- throw new Error('Try to filter all-local but no user has not the see all videos right')
- }
+ VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
+ VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
const trendingDays = options.sort.endsWith('trending')
? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
: undefined
- let trendingAlgorithm
+
+ let trendingAlgorithm: string
if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
const serverActor = await getServerActor()
- // followerActorId === null has a meaning, so just check undefined
- const followerActorId = options.followerActorId !== undefined
- ? options.followerActorId
- : serverActor.id
-
const queryOptions = {
- start: options.start,
- count: options.count,
- sort: options.sort,
- followerActorId,
- serverAccountId: serverActor.Account.id,
- nsfw: options.nsfw,
- isLive: options.isLive,
- categoryOneOf: options.categoryOneOf,
- licenceOneOf: options.licenceOneOf,
- languageOneOf: options.languageOneOf,
- tagsOneOf: options.tagsOneOf,
- tagsAllOf: options.tagsAllOf,
- filter: options.filter,
- withFiles: options.withFiles,
- accountId: options.accountId,
- videoChannelId: options.videoChannelId,
- videoPlaylistId: options.videoPlaylistId,
- includeLocalVideos: options.includeLocalVideos,
- user: options.user,
- historyOfUser: options.historyOfUser,
+ ...pick(options, [
+ 'start',
+ 'count',
+ 'sort',
+ 'nsfw',
+ 'isLive',
+ 'categoryOneOf',
+ 'licenceOneOf',
+ 'languageOneOf',
+ 'tagsOneOf',
+ 'tagsAllOf',
+ 'privacyOneOf',
+ 'isLocal',
+ 'include',
+ 'displayOnlyForFollower',
+ 'hasFiles',
+ 'accountId',
+ 'videoChannelId',
+ 'videoPlaylistId',
+ 'user',
+ 'historyOfUser',
+ 'hasHLSFiles',
+ 'hasWebtorrentFiles',
+ 'search'
+ ]),
+
+ serverAccountIdForBlock: serverActor.Account.id,
trendingDays,
- trendingAlgorithm,
- search: options.search
+ trendingAlgorithm
}
return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
}
static async searchAndPopulateAccountAndServer (options: {
- includeLocalVideos: boolean
- search?: string
- start?: number
- count?: number
- sort?: string
- startDate?: string // ISO 8601
- endDate?: string // ISO 8601
- originallyPublishedStartDate?: string
- originallyPublishedEndDate?: string
+ start: number
+ count: number
+ sort: string
+
nsfw?: boolean
isLive?: boolean
+ isLocal?: boolean
+ include?: VideoInclude
+
categoryOneOf?: number[]
licenceOneOf?: number[]
languageOneOf?: string[]
tagsOneOf?: string[]
tagsAllOf?: string[]
- durationMin?: number // seconds
- durationMax?: number // seconds
- user?: MUserAccountId
- filter?: VideoFilter
- }) {
- const serverActor = await getServerActor()
-
- const queryOptions = {
- followerActorId: serverActor.id,
- serverAccountId: serverActor.Account.id,
-
- includeLocalVideos: options.includeLocalVideos,
- nsfw: options.nsfw,
- isLive: options.isLive,
+ privacyOneOf?: VideoPrivacy[]
- categoryOneOf: options.categoryOneOf,
- licenceOneOf: options.licenceOneOf,
- languageOneOf: options.languageOneOf,
+ displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
- tagsOneOf: options.tagsOneOf,
- tagsAllOf: options.tagsAllOf,
+ user?: MUserAccountId
- user: options.user,
- filter: options.filter,
+ hasWebtorrentFiles?: boolean
+ hasHLSFiles?: boolean
- start: options.start,
- count: options.count,
- sort: options.sort,
+ search?: string
- startDate: options.startDate,
- endDate: options.endDate,
+ host?: string
+ startDate?: string // ISO 8601
+ endDate?: string // ISO 8601
+ originallyPublishedStartDate?: string
+ originallyPublishedEndDate?: string
- originallyPublishedStartDate: options.originallyPublishedStartDate,
- originallyPublishedEndDate: options.originallyPublishedEndDate,
+ durationMin?: number // seconds
+ durationMax?: number // seconds
+ uuids?: string[]
+ }) {
+ VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
+ VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
- durationMin: options.durationMin,
- durationMax: options.durationMax,
+ const serverActor = await getServerActor()
- search: options.search
+ const queryOptions = {
+ ...pick(options, [
+ 'include',
+ 'nsfw',
+ 'isLive',
+ 'categoryOneOf',
+ 'licenceOneOf',
+ 'languageOneOf',
+ 'tagsOneOf',
+ 'tagsAllOf',
+ 'privacyOneOf',
+ 'user',
+ 'isLocal',
+ 'host',
+ 'start',
+ 'count',
+ 'sort',
+ 'startDate',
+ 'endDate',
+ 'originallyPublishedStartDate',
+ 'originallyPublishedEndDate',
+ 'durationMin',
+ 'durationMax',
+ 'hasHLSFiles',
+ 'hasWebtorrentFiles',
+ 'uuids',
+ 'search',
+ 'displayOnlyForFollower'
+ ]),
+ serverAccountIdForBlock: serverActor.Account.id
}
return VideoModel.getAvailableForApi(queryOptions)
}
static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
}
static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
}
}
static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ id, transaction, type: 'id' })
}
static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
}
static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
}
static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
}
static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
}
userId?: number
}): Promise<MVideoDetails> {
const { id, transaction, userId } = parameters
- const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
+ const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
}
// Sequelize could return null...
if (!totalLocalVideoViews) totalLocalVideoViews = 0
+ const serverActor = await getServerActor()
+
const { total: totalVideos } = await VideoModel.listForApi({
start: 0,
count: 0,
sort: '-publishedAt',
nsfw: buildNSFWFilter(),
- includeLocalVideos: true,
- withFiles: false
+ displayOnlyForFollower: {
+ actorId: serverActor.id,
+ orLocalVideos: true
+ }
})
return {
// threshold corresponds to how many video the field should have to be returned
static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
const serverActor = await getServerActor()
- const followerActorId = serverActor.id
const queryOptions: BuildVideosListQueryOptions = {
attributes: [ `"${field}"` ],
start: 0,
sort: 'random',
count,
- serverAccountId: serverActor.Account.id,
- followerActorId,
- includeLocalVideos: true
+ serverAccountIdForBlock: serverActor.Account.id,
+ displayOnlyForFollower: {
+ actorId: serverActor.id,
+ orLocalVideos: true
+ }
}
const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
required: false,
where: {
startDate: {
- [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
+ // FIXME: ts error
+ [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
}
}
}
}
}
+ private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
+ if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
+ throw new Error('Try to filter all-local but user cannot see all videos')
+ }
+ }
+
+ private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
+ if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
+ throw new Error('Try to choose video privacies but user cannot see all videos')
+ }
+ }
+
+ private static isPrivateInclude (include: VideoInclude) {
+ return include & VideoInclude.BLACKLISTED ||
+ include & VideoInclude.BLOCKED_OWNER ||
+ include & VideoInclude.NOT_PUBLISHED_STATE
+ }
+
isBlacklisted () {
return !!this.VideoBlacklist
}
if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
- // Already have this thumbnail, skip
- if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
-
+ this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
this.Thumbnails.push(savedThumbnail)
}
}
getWatchStaticPath () {
- return '/w/' + this.uuid
+ return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
}
getEmbedStaticPath () {
- return '/videos/embed/' + this.uuid
+ return buildVideoEmbedPath(this)
}
getMiniatureStaticPath () {
return peertubeTruncate(this.description, { length: maxLength })
}
- getMaxQualityResolution () {
+ getMaxQualityFileInfo () {
const file = this.getMaxQualityFile()
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
- const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
- return getVideoFileResolution(originalFilePath)
+ return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
+ const probe = await ffprobePromise(originalFilePath)
+
+ const { audioStream } = await getAudioStream(originalFilePath, probe)
+
+ return {
+ audioStream,
+
+ ...await getVideoFileResolution(originalFilePath, probe)
+ }
+ })
}
getDescriptionAPIPath () {
if (!this.VideoStreamingPlaylists) return undefined
const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+ if (!playlist) return undefined
+
playlist.Video = this
return playlist
.concat(toAdd)
}
- removeFile (videoFile: MVideoFile, isRedundancy = false) {
- const filePath = getVideoFilePath(this, videoFile, isRedundancy)
- return remove(filePath)
- .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
+ removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
+ const filePath = isRedundancy
+ ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
+ : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
+
+ const promises: Promise<any>[] = [ remove(filePath) ]
+ if (!isRedundancy) promises.push(videoFile.removeTorrent())
+
+ if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
+ promises.push(removeWebTorrentObjectStorage(videoFile))
+ }
+
+ return Promise.all(promises)
}
async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
- const directoryPath = getHLSDirectory(this, isRedundancy)
+ const directoryPath = isRedundancy
+ ? getHLSRedundancyDirectory(this)
+ : getHLSDirectory(this)
await remove(directoryPath)
await Promise.all(
streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
)
+
+ if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
+ await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
+ }
}
}
return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
}
- setAsRefreshed () {
- return setAsUpdated('video', this.id)
+ setAsRefreshed (transaction?: Transaction) {
+ return setAsUpdated('video', this.id, transaction)
}
requiresAuth () {
this.privacy === VideoPrivacy.INTERNAL
}
- async publishIfNeededAndSave (t: Transaction) {
- if (this.state !== VideoState.PUBLISHED) {
- this.state = VideoState.PUBLISHED
- this.publishedAt = new Date()
- await this.save({ transaction: t })
+ async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
+ if (this.state === newState) throw new Error('Cannot use same state ' + newState)
- return true
+ this.state = newState
+
+ if (this.state === VideoState.PUBLISHED && isNewVideo) {
+ this.publishedAt = new Date()
}
- return false
+ await this.save({ transaction })
}
- getBandwidthBits (videoFile: MVideoFile) {
+ getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
return Math.ceil((videoFile.size * 8) / this.duration)
}