]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Merge branch 'release/4.2.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index f9618c102fbdcd9450ba1bbd117c7586c0aee9fd..e6a8d3f9550475a3f7a13aa249eeca31194658b0 100644 (file)
@@ -25,7 +25,6 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import { buildNSFWFilter } from '@server/helpers/express-utils'
-import { uuidToShort } from '@server/helpers/uuid'
 import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
 import { LiveManager } from '@server/lib/live/live-manager'
 import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
@@ -33,14 +32,24 @@ 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, buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
-import { VideoInclude } from '@shared/models'
-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, VideoStorage } from '../../../shared/models/videos'
-import { ThumbnailType } from '../../../shared/models/videos/thumbnail.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 { exists, isBooleanValid } from '../../helpers/custom-validators/misc'
@@ -52,7 +61,7 @@ import {
   isVideoStateValid,
   isVideoSupportValid
 } from '../../helpers/custom-validators/videos'
-import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
+import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
 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'
@@ -97,6 +106,7 @@ import { setAsUpdated } 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,
@@ -105,9 +115,13 @@ import {
   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'
@@ -122,7 +136,6 @@ import { VideoPlaylistElementModel } from './video-playlist-element'
 import { VideoShareModel } from './video-share'
 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
 import { VideoTagModel } from './video-tag'
-import { VideoViewModel } from './video-view'
 
 export enum ScopeNames {
   FOR_API = 'FOR_API',
@@ -220,8 +233,8 @@ export type ForAPIOptions = {
                 required: false
               },
               {
-                model: ActorImageModel.unscoped(),
-                as: 'Avatar',
+                model: ActorImageModel,
+                as: 'Avatars',
                 required: false
               }
             ]
@@ -243,8 +256,8 @@ export type ForAPIOptions = {
                     required: false
                   },
                   {
-                    model: ActorImageModel.unscoped(),
-                    as: 'Avatar',
+                    model: ActorImageModel,
+                    as: 'Avatars',
                     required: false
                   }
                 ]
@@ -746,7 +759,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
       // Remove physical files and torrents
       instance.VideoFiles.forEach(file => {
-        tasks.push(instance.removeFileAndTorrent(file))
+        tasks.push(instance.removeWebTorrentFileAndTorrent(file))
       })
 
       // Remove playlists file
@@ -774,7 +787,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
     logger.info('Stopping live of video %s after video deletion.', instance.uuid)
 
-    LiveManager.Instance.stopSessionOf(instance.id)
+    LiveManager.Instance.stopSessionOf(instance.id, null)
   }
 
   @BeforeDestroy
@@ -805,14 +818,17 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     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) {
@@ -956,7 +972,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
   }) {
     const { accountId, channelId, start, count, sort, search, isLive } = options
 
-    function buildBaseQuery (): FindOptions {
+    function buildBaseQuery (forCount: boolean): FindOptions {
       const where: WhereOptions = {}
 
       if (search) {
@@ -985,7 +1001,9 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
             where: channelWhere,
             include: [
               {
-                model: AccountModel,
+                model: forCount
+                  ? AccountModel.unscoped()
+                  : AccountModel,
                 where: {
                   id: accountId
                 },
@@ -999,8 +1017,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
       return baseQuery
     }
 
-    const countQuery = buildBaseQuery()
-    const findQuery = buildBaseQuery()
+    const countQuery = buildBaseQuery(true)
+    const findQuery = buildBaseQuery(false)
 
     const findScopes: (string | ScopeOptions)[] = [
       ScopeNames.WITH_SCHEDULED_UPDATE,
@@ -1030,12 +1048,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     include?: VideoInclude
 
     hasFiles?: boolean // default false
+    hasWebtorrentFiles?: boolean
+    hasHLSFiles?: boolean
 
     categoryOneOf?: number[]
     licenceOneOf?: number[]
     languageOneOf?: string[]
     tagsOneOf?: string[]
     tagsAllOf?: string[]
+    privacyOneOf?: VideoPrivacy[]
 
     accountId?: number
     videoChannelId?: number
@@ -1053,9 +1074,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
     search?: string
   }) {
-    if (VideoModel.isPrivateInclude(options.include) && !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
@@ -1079,6 +1099,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
         'languageOneOf',
         'tagsOneOf',
         'tagsAllOf',
+        'privacyOneOf',
         'isLocal',
         'include',
         'displayOnlyForFollower',
@@ -1088,6 +1109,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
         'videoPlaylistId',
         'user',
         'historyOfUser',
+        'hasHLSFiles',
+        'hasWebtorrentFiles',
         'search'
       ]),
 
@@ -1103,27 +1126,41 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     start: number
     count: number
     sort: string
-    search?: string
-    host?: string
-    startDate?: string // ISO 8601
-    endDate?: string // ISO 8601
-    originallyPublishedStartDate?: string
-    originallyPublishedEndDate?: string
+
     nsfw?: boolean
     isLive?: boolean
     isLocal?: boolean
     include?: VideoInclude
+
     categoryOneOf?: number[]
     licenceOneOf?: number[]
     languageOneOf?: string[]
     tagsOneOf?: string[]
     tagsAllOf?: string[]
+    privacyOneOf?: VideoPrivacy[]
+
+    displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
+
+    user?: MUserAccountId
+
+    hasWebtorrentFiles?: boolean
+    hasHLSFiles?: boolean
+
+    search?: string
+
+    host?: string
+    startDate?: string // ISO 8601
+    endDate?: string // ISO 8601
+    originallyPublishedStartDate?: string
+    originallyPublishedEndDate?: string
+
     durationMin?: number // seconds
     durationMax?: number // seconds
-    user?: MUserAccountId
     uuids?: string[]
-    displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
   }) {
+    VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
+    VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
+
     const serverActor = await getServerActor()
 
     const queryOptions = {
@@ -1136,6 +1173,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
         'languageOneOf',
         'tagsOneOf',
         'tagsAllOf',
+        'privacyOneOf',
         'user',
         'isLocal',
         'host',
@@ -1148,6 +1186,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
         'originallyPublishedEndDate',
         'durationMin',
         'durationMax',
+        'hasHLSFiles',
+        'hasWebtorrentFiles',
         'uuids',
         'search',
         'displayOnlyForFollower'
@@ -1364,7 +1404,21 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     })
   }
 
-  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'
@@ -1454,7 +1508,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
       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)
         }
       }
     }
@@ -1489,10 +1544,21 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     }
   }
 
+  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.HIDDEN_PRIVACY ||
            include & VideoInclude.NOT_PUBLISHED_STATE
   }
 
@@ -1551,9 +1617,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
     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)
   }
 
@@ -1635,12 +1699,38 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     return peertubeTruncate(this.description, { length: maxLength })
   }
 
-  getMaxQualityResolution () {
+  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()
 
-    return VideoPathManager.Instance.makeAvailableVideoFile(videoOrPlaylist, file, originalFilePath => {
-      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 getVideoStreamDimensionsInfo(originalFilePath, probe)
+      }
     })
   }
 
@@ -1652,6 +1742,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     if (!this.VideoStreamingPlaylists) return undefined
 
     const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+    if (!playlist) return undefined
+
     playlist.Video = this
 
     return playlist
@@ -1670,7 +1762,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
                                        .concat(toAdd)
   }
 
-  removeFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
+  removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
     const filePath = isRedundancy
       ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
       : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
@@ -1706,7 +1798,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
       )
 
       if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
-        await removeHLSObjectStorage(streamingPlaylist, this)
+        await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
       }
     }
   }
@@ -1729,8 +1821,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
   }
 
-  setAsRefreshed () {
-    return setAsUpdated('video', this.id)
+  setAsRefreshed (transaction?: Transaction) {
+    return setAsUpdated('video', this.id, transaction)
   }
 
   requiresAuth () {
@@ -1763,7 +1855,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     await this.save({ transaction })
   }
 
-  getBandwidthBits (videoFile: MVideoFile) {
+  getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
     return Math.ceil((videoFile.size * 8) / this.duration)
   }