]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Add expect message to ease debug
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index 924f12a5ed41208ae3da30f48f209f1c69702bea..baa8c120a1ddee8eb7d9e7b50093a4376140b9f9 100644 (file)
@@ -26,13 +26,17 @@ import {
 } from 'sequelize-typescript'
 import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
 import { LiveManager } from '@server/lib/live/live-manager'
-import { removeHLSFileObjectStorage, 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,
@@ -51,7 +55,7 @@ import {
 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,
@@ -60,7 +64,6 @@ import {
   isVideoStateValid,
   isVideoSupportValid
 } from '../../helpers/custom-validators/videos'
-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'
@@ -101,10 +104,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
 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,
@@ -704,6 +706,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
       name: 'videoId',
       allowNull: false
     },
+    hooks: true,
     onDelete: 'cascade'
   })
   VideoLive: VideoLiveModel
@@ -768,7 +771,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
       // Remove physical files and torrents
       instance.VideoFiles.forEach(file => {
-        tasks.push(instance.removeWebTorrentFileAndTorrent(file))
+        tasks.push(instance.removeWebTorrentFile(file))
       })
 
       // Remove playlists file
@@ -783,9 +786,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
     // 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
   }
@@ -796,7 +798,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, null)
+    LiveManager.Instance.stopSessionOf(instance.uuid, null)
   }
 
   @BeforeDestroy
@@ -1084,6 +1086,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     countVideos?: boolean
 
     search?: string
+
+    excludeAlreadyWatched?: boolean
   }) {
     VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
     VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@@ -1122,7 +1126,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
         'historyOfUser',
         'hasHLSFiles',
         'hasWebtorrentFiles',
-        'search'
+        'search',
+        'excludeAlreadyWatched'
       ]),
 
       serverAccountIdForBlock: serverActor.Account.id,
@@ -1168,6 +1173,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     durationMin?: number // seconds
     durationMax?: number // seconds
     uuids?: string[]
+
+    excludeAlreadyWatched?: boolean
   }) {
     VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
     VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@@ -1201,7 +1208,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
         'hasWebtorrentFiles',
         'uuids',
         'search',
-        'displayOnlyForFollower'
+        'displayOnlyForFollower',
+        'excludeAlreadyWatched'
       ]),
       serverAccountIdForBlock: serverActor.Account.id
     }
@@ -1457,6 +1465,12 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     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 = {
@@ -1535,6 +1549,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     options: BuildVideosListQueryOptions,
     countVideos = true
   ): Promise<ResultList<VideoModel>> {
+    const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
+
     function getCount () {
       if (countVideos !== true) return Promise.resolve(undefined)
 
@@ -1554,6 +1570,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
     const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
 
+    span.end()
+
     return {
       data: rows,
       total: count
@@ -1587,22 +1605,21 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
   }
 
   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 {
@@ -1692,20 +1709,24 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     let files: VideoFile[] = []
 
     if (Array.isArray(this.VideoFiles)) {
-      const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
+      const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
       files = files.concat(result)
     }
 
     for (const p of (this.VideoStreamingPlaylists || [])) {
-      const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
+      const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet })
       files = files.concat(result)
     }
 
     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 () {
@@ -1741,9 +1762,13 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
       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 getVideoStreamDimensionsInfo(originalFilePath, probe)
       }
@@ -1760,9 +1785,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     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) {
@@ -1778,7 +1801,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
                                        .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)
@@ -1824,8 +1847,12 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     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 removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), videoFile.filename)
+      await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
+      await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
     }
   }
 
@@ -1834,7 +1861,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     await remove(filePath)
 
     if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
-      await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), filename)
+      await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
     }
   }
 
@@ -1857,27 +1884,42 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
   }
 
   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)
 
@@ -1891,6 +1933,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
   }
 
   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)
   }