+ VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
+
+ @HasMany(() => VideoShareModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ VideoShares: VideoShareModel[]
+
+ @HasMany(() => AccountVideoRateModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ AccountVideoRates: AccountVideoRateModel[]
+
+ @HasMany(() => VideoCommentModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade',
+ hooks: true
+ })
+ VideoComments: VideoCommentModel[]
+
+ @HasMany(() => VideoViewModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ VideoViews: VideoViewModel[]
+
+ @HasMany(() => UserVideoHistoryModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ UserVideoHistories: UserVideoHistoryModel[]
+
+ @HasOne(() => ScheduleVideoUpdateModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ ScheduleVideoUpdate: ScheduleVideoUpdateModel
+
+ @HasOne(() => VideoBlacklistModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ VideoBlacklist: VideoBlacklistModel
+
+ @HasOne(() => VideoImportModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: true
+ },
+ onDelete: 'set null'
+ })
+ VideoImport: VideoImportModel
+
+ @HasMany(() => VideoCaptionModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade',
+ hooks: true,
+ [ 'separate' as any ]: true
+ })
+ VideoCaptions: VideoCaptionModel[]
+
+ @BeforeDestroy
+ static async sendDelete (instance: VideoModel, options) {
+ if (instance.isOwned()) {
+ if (!instance.VideoChannel) {
+ instance.VideoChannel = await instance.$get('VideoChannel', {
+ include: [
+ {
+ model: AccountModel,
+ include: [ ActorModel ]
+ }
+ ],
+ transaction: options.transaction
+ }) as VideoChannelModel
+ }
+
+ return sendDeleteVideo(instance, options.transaction)
+ }
+
+ return undefined
+ }
+
+ @BeforeDestroy
+ static async removeFiles (instance: VideoModel) {
+ const tasks: Promise<any>[] = []
+
+ logger.info('Removing files of video %s.', instance.url)
+
+ if (instance.isOwned()) {
+ if (!Array.isArray(instance.VideoFiles)) {
+ instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
+ }
+
+ // Remove physical files and torrents
+ instance.VideoFiles.forEach(file => {
+ tasks.push(instance.removeFile(file))
+ tasks.push(instance.removeTorrent(file))
+ })
+
+ // Remove playlists file
+ tasks.push(instance.removeStreamingPlaylist())
+ }
+
+ // 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 })
+ })
+
+ return undefined
+ }
+
+ static listLocal () {
+ const query = {
+ where: {
+ remote: false
+ }
+ }
+
+ return VideoModel.scope([
+ ScopeNames.WITH_FILES,
+ ScopeNames.WITH_STREAMING_PLAYLISTS,
+ ScopeNames.WITH_THUMBNAILS
+ ]).findAll(query)
+ }
+
+ static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
+ function getRawQuery (select: string) {
+ const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
+ 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
+ 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
+ 'WHERE "Account"."actorId" = ' + actorId
+ const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
+ 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
+ 'WHERE "VideoShare"."actorId" = ' + actorId
+
+ return `(${queryVideo}) UNION (${queryVideoShare})`
+ }
+
+ const rawQuery = getRawQuery('"Video"."id"')
+ const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
+
+ const query = {
+ distinct: true,
+ offset: start,
+ limit: count,
+ order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
+ where: {
+ id: {
+ [ Op.in ]: Sequelize.literal('(' + rawQuery + ')')
+ },
+ [ Op.or ]: [
+ { privacy: VideoPrivacy.PUBLIC },
+ { privacy: VideoPrivacy.UNLISTED }
+ ]
+ },
+ include: [
+ {
+ attributes: [ 'language' ],
+ model: VideoCaptionModel.unscoped(),
+ required: false
+ },
+ {
+ attributes: [ 'id', 'url' ],
+ model: VideoShareModel.unscoped(),
+ required: false,
+ // We only want videos shared by this actor
+ where: {
+ [ Op.and ]: [
+ {
+ id: {
+ [ Op.not ]: null
+ }
+ },
+ {
+ actorId
+ }
+ ]
+ },
+ include: [
+ {
+ attributes: [ 'id', 'url' ],
+ model: ActorModel.unscoped()
+ }
+ ]
+ },
+ {
+ model: VideoChannelModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [ 'name' ],
+ model: AccountModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [ 'id', 'url', 'followersUrl' ],
+ model: ActorModel.unscoped(),
+ required: true
+ }
+ ]
+ },
+ {
+ attributes: [ 'id', 'url', 'followersUrl' ],
+ model: ActorModel.unscoped(),
+ required: true
+ }
+ ]
+ },
+ VideoFileModel,
+ TagModel
+ ]
+ }
+
+ return Bluebird.all([
+ VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
+ VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
+ ]).then(([ rows, totals ]) => {
+ // totals: totalVideos + totalVideoShares
+ let totalVideos = 0
+ let totalVideoShares = 0
+ if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10)
+ if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10)
+
+ const total = totalVideos + totalVideoShares
+ return {
+ data: rows,
+ total: total
+ }
+ })
+ }
+
+ static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) {
+ function buildBaseQuery (): FindOptions {
+ return {
+ offset: start,
+ limit: count,
+ order: getVideoSort(sort),
+ include: [
+ {
+ model: VideoChannelModel,
+ required: true,
+ include: [
+ {
+ model: AccountModel,
+ where: {
+ id: accountId
+ },
+ required: true
+ }
+ ]
+ }
+ ]
+ }
+ }
+
+ const countQuery = buildBaseQuery()
+ const findQuery = buildBaseQuery()
+
+ const findScopes = [
+ ScopeNames.WITH_SCHEDULED_UPDATE,
+ ScopeNames.WITH_BLACKLISTED,
+ ScopeNames.WITH_THUMBNAILS
+ ]
+
+ if (withFiles === true) {
+ findQuery.include.push({
+ model: VideoFileModel.unscoped(),
+ required: true
+ })
+ }
+
+ return Promise.all([
+ VideoModel.count(countQuery),
+ VideoModel.scope(findScopes).findAll(findQuery)
+ ]).then(([ count, rows ]) => {
+ return {
+ data: rows,
+ total: count
+ }
+ })
+ }
+
+ static async listForApi (options: {
+ start: number,
+ count: number,
+ sort: string,
+ nsfw: boolean,
+ includeLocalVideos: boolean,
+ withFiles: boolean,
+ categoryOneOf?: number[],
+ licenceOneOf?: number[],
+ languageOneOf?: string[],
+ tagsOneOf?: string[],
+ tagsAllOf?: string[],
+ filter?: VideoFilter,
+ accountId?: number,
+ videoChannelId?: number,
+ followerActorId?: number
+ videoPlaylistId?: number,
+ trendingDays?: number,
+ user?: UserModel,
+ historyOfUser?: UserModel
+ }, countVideos = true) {
+ if (options.filter && options.filter === 'all-local' && !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')
+ }
+
+ const query: FindOptions & { where?: null } = {
+ offset: options.start,
+ limit: options.count,
+ order: getVideoSort(options.sort)
+ }
+
+ let trendingDays: number
+ if (options.sort.endsWith('trending')) {
+ trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
+
+ query.group = 'VideoModel.id'
+ }
+
+ const serverActor = await getServerActor()
+
+ // followerActorId === null has a meaning, so just check undefined
+ const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
+
+ const queryOptions = {
+ followerActorId,
+ serverAccountId: serverActor.Account.id,
+ nsfw: options.nsfw,
+ 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,
+ trendingDays
+ }
+
+ return VideoModel.getAvailableForApi(query, queryOptions, 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
+ nsfw?: boolean
+ categoryOneOf?: number[]
+ licenceOneOf?: number[]
+ languageOneOf?: string[]
+ tagsOneOf?: string[]
+ tagsAllOf?: string[]
+ durationMin?: number // seconds
+ durationMax?: number // seconds
+ user?: UserModel,
+ filter?: VideoFilter
+ }) {
+ const whereAnd = []
+
+ if (options.startDate || options.endDate) {
+ const publishedAtRange = {}
+
+ if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
+ if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
+
+ whereAnd.push({ publishedAt: publishedAtRange })
+ }
+
+ if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
+ const originallyPublishedAtRange = {}
+
+ if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
+ if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
+
+ whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
+ }
+
+ if (options.durationMin || options.durationMax) {
+ const durationRange = {}
+
+ if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
+ if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
+
+ whereAnd.push({ duration: durationRange })
+ }
+
+ const attributesInclude = []
+ const escapedSearch = VideoModel.sequelize.escape(options.search)
+ const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
+ if (options.search) {
+ whereAnd.push(
+ {
+ id: {
+ [ Op.in ]: Sequelize.literal(
+ '(' +
+ 'SELECT "video"."id" FROM "video" ' +
+ 'WHERE ' +
+ 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
+ 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
+ 'UNION ALL ' +
+ 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
+ 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
+ 'WHERE "tag"."name" = ' + escapedSearch +
+ ')'
+ )
+ }
+ }
+ )
+
+ attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
+ }
+
+ // Cannot search on similarity if we don't have a search
+ if (!options.search) {
+ attributesInclude.push(
+ Sequelize.literal('0 as similarity')
+ )
+ }
+
+ const query = {
+ attributes: {
+ include: attributesInclude
+ },
+ offset: options.start,
+ limit: options.count,
+ order: getVideoSort(options.sort)
+ }
+
+ const serverActor = await getServerActor()
+ const queryOptions = {
+ followerActorId: serverActor.id,
+ serverAccountId: serverActor.Account.id,
+ includeLocalVideos: options.includeLocalVideos,
+ nsfw: options.nsfw,
+ categoryOneOf: options.categoryOneOf,
+ licenceOneOf: options.licenceOneOf,
+ languageOneOf: options.languageOneOf,
+ tagsOneOf: options.tagsOneOf,
+ tagsAllOf: options.tagsAllOf,
+ user: options.user,
+ filter: options.filter,
+ baseWhere: whereAnd
+ }
+
+ return VideoModel.getAvailableForApi(query, queryOptions)
+ }
+
+ static load (id: number | string, t?: Transaction) {
+ const where = buildWhereIdOrUUID(id)
+ const options = {
+ where,
+ transaction: t
+ }
+
+ return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
+ }
+
+ static loadWithRights (id: number | string, t?: Transaction) {
+ const where = buildWhereIdOrUUID(id)
+ const options = {
+ where,
+ transaction: t
+ }
+
+ return VideoModel.scope([
+ ScopeNames.WITH_BLACKLISTED,
+ ScopeNames.WITH_USER_ID,
+ ScopeNames.WITH_THUMBNAILS
+ ]).findOne(options)
+ }
+
+ static loadOnlyId (id: number | string, t?: Transaction) {
+ const where = buildWhereIdOrUUID(id)
+
+ const options = {
+ attributes: [ 'id' ],
+ where,
+ transaction: t
+ }
+
+ return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
+ }
+
+ static loadWithFiles (id: number, t?: Transaction, logging?: boolean) {
+ return VideoModel.scope([
+ ScopeNames.WITH_FILES,
+ ScopeNames.WITH_STREAMING_PLAYLISTS,
+ ScopeNames.WITH_THUMBNAILS
+ ]).findByPk(id, { transaction: t, logging })
+ }
+
+ static loadByUUIDWithFile (uuid: string) {
+ const options = {
+ where: {
+ uuid
+ }
+ }
+
+ return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
+ }
+
+ static loadByUrl (url: string, transaction?: Transaction) {
+ const query: FindOptions = {
+ where: {
+ url
+ },
+ transaction
+ }
+
+ return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
+ }
+
+ static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction) {
+ const query: FindOptions = {
+ where: {
+ url
+ },
+ transaction
+ }
+
+ return VideoModel.scope([
+ ScopeNames.WITH_ACCOUNT_DETAILS,
+ ScopeNames.WITH_FILES,
+ ScopeNames.WITH_STREAMING_PLAYLISTS,
+ ScopeNames.WITH_THUMBNAILS
+ ]).findOne(query)
+ }
+
+ static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number) {
+ const where = buildWhereIdOrUUID(id)
+
+ const options = {
+ order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
+ where,
+ transaction: t
+ }
+
+ const scopes: (string | ScopeOptions)[] = [
+ ScopeNames.WITH_TAGS,
+ ScopeNames.WITH_BLACKLISTED,
+ ScopeNames.WITH_ACCOUNT_DETAILS,
+ ScopeNames.WITH_SCHEDULED_UPDATE,
+ ScopeNames.WITH_FILES,
+ ScopeNames.WITH_STREAMING_PLAYLISTS,
+ ScopeNames.WITH_THUMBNAILS
+ ]
+
+ if (userId) {
+ scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
+ }