]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add video edition finished notification
authorChocobozzz <me@florianbigard.com>
Tue, 22 Mar 2022 13:35:04 +0000 (14:35 +0100)
committerChocobozzz <me@florianbigard.com>
Tue, 22 Mar 2022 15:25:14 +0000 (16:25 +0100)
30 files changed:
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
client/src/app/shared/shared-main/users/user-notification.model.ts
client/src/app/shared/shared-main/users/user-notifications.component.html
scripts/create-move-video-storage-job.ts
server/controllers/api/users/my-notifications.ts
server/controllers/api/videos/upload.ts
server/initializers/constants.ts
server/initializers/migrations/0700-edition-finished-notification.ts [new file with mode: 0644]
server/lib/job-queue/handlers/move-to-object-storage.ts
server/lib/job-queue/handlers/video-edition.ts
server/lib/job-queue/handlers/video-file-import.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/job-queue/handlers/video-live-ending.ts
server/lib/job-queue/handlers/video-transcoding.ts
server/lib/notifier/notifier.ts
server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts
server/lib/notifier/shared/video-publication/edition-finished-for-owner.ts [new file with mode: 0644]
server/lib/notifier/shared/video-publication/index.ts
server/lib/user.ts
server/lib/video-state.ts
server/lib/video.ts
server/models/user/user-notification-setting.ts
server/tests/api/check-params/user-notifications.ts
server/tests/api/notifications/user-notifications.ts
server/tests/api/transcoding/video-editor.ts
server/tests/shared/notifications.ts
shared/models/server/job.model.ts
shared/models/users/user-notification-setting.model.ts
shared/models/users/user-notification.model.ts
shared/server-commands/server/config-command.ts

index 09da979ab4d404b4beb909164bd08ae9bcbb635b..187a3818a4cac585d562380e732a2d1f4e2716b4 100644 (file)
@@ -44,7 +44,8 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
       abuseNewMessage: $localize`An abuse report received a new message`,
       abuseStateChange: $localize`One of your abuse reports has been accepted or rejected by moderators`,
       newPeerTubeVersion: $localize`A new PeerTube version is available`,
-      newPluginVersion: $localize`One of your plugin/theme has a new available version`
+      newPluginVersion: $localize`One of your plugin/theme has a new available version`,
+      myVideoEditionFinished: $localize`Video edition finished`
     }
     this.notificationSettingGroups = [
       {
@@ -62,7 +63,8 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
           'newCommentOnMyVideo',
           'blacklistOnMyVideo',
           'myVideoPublished',
-          'myVideoImportFinished'
+          'myVideoImportFinished',
+          'myVideoEditionFinished'
         ]
       },
 
index 1eb69d5a22fea3bdd9ced81c0e729339f922a15c..d1b36f3473a6599e2fc9c3bf19f4f7a28a168327 100644 (file)
@@ -227,6 +227,10 @@ export class UserNotification implements UserNotificationServer {
           this.pluginUrl = `/admin/plugins/list-installed`
           this.pluginQueryParams.pluginType = this.plugin.type + ''
           break
+
+        case UserNotificationType.MY_VIDEO_EDITION_FINISHED:
+          this.videoUrl = this.buildVideoUrl(this.video)
+          break
       }
     } catch (err) {
       this.type = null
index 9af6da784bcd6e161eee36e2125d2861c44438e3..ff1259fb8a34a80720359f89f05b23931d2f827e 100644 (file)
         <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
 
         <div class="message" i18n>
-            <a (click)="markAsRead(notification)" [href]="notification.peertubeVersionLink" target="_blank" rel="noopener noreferrer">A new version of PeerTube</a> is available: {{ notification.peertube.latestVersion }}
+          <a (click)="markAsRead(notification)" [href]="notification.peertubeVersionLink" target="_blank" rel="noopener noreferrer">A new version of PeerTube</a> is available: {{ notification.peertube.latestVersion }}
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="19"> <!-- UserNotificationType.MY_VIDEO_EDITION_FINISHED -->
+        <my-global-icon iconName="film" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> edition has finished
         </div>
       </ng-container>
 
index 7465c1ce0ec42b9a078eca7beeb805ac649b34ec..18629aa27dad54f94a963a7866657542f067220e 100644 (file)
@@ -78,7 +78,7 @@ async function run () {
     if (files.some(f => f.storage === VideoStorage.FILE_SYSTEM) || hls?.storage === VideoStorage.FILE_SYSTEM) {
       console.log('Processing video %s.', videoFull.name)
 
-      const success = await moveToExternalStorageState(videoFull, false, undefined)
+      const success = await moveToExternalStorageState({ video: videoFull, isNewVideo: false, transaction: undefined })
 
       if (!success) {
         console.error(
index 58732158ffca143c2bdd997d624544dabd3340e5..55184dc0fa64cb25fa3a09fc3afceceb3d968761 100644 (file)
@@ -82,7 +82,8 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
     abuseNewMessage: body.abuseNewMessage,
     abuseStateChange: body.abuseStateChange,
     newPeerTubeVersion: body.newPeerTubeVersion,
-    newPluginVersion: body.newPluginVersion
+    newPluginVersion: body.newPluginVersion,
+    myVideoEditionFinished: body.myVideoEditionFinished
   }
 
   await UserNotificationSettingModel.update(values, query)
index 14ae9d920a4b005f0e20138616efc96f7d6991f1..3afbedbb2ecd5811851eb33858d30d707fb97e9b 100644 (file)
@@ -218,11 +218,11 @@ async function addVideo (options: {
       if (!refreshedVideo) return
 
       if (refreshedVideo.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
-        return addMoveToObjectStorageJob(refreshedVideo)
+        return addMoveToObjectStorageJob({ video: refreshedVideo, previousVideoState: undefined })
       }
 
       if (refreshedVideo.state === VideoState.TO_TRANSCODE) {
-        return addOptimizeOrMergeAudioJob(refreshedVideo, videoFile, user)
+        return addOptimizeOrMergeAudioJob({ video: refreshedVideo, videoFile, user })
       }
     }).catch(err => logger.error('Cannot add optimize/merge audio job for %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
 
index aaf39e6ec2bea889345ca61c2803a1cc609a7582..17d8ba556c08ef2d1e65eae24b2c61b0846843d5 100644 (file)
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 695
+const LAST_MIGRATION_VERSION = 700
 
 // ---------------------------------------------------------------------------
 
diff --git a/server/initializers/migrations/0700-edition-finished-notification.ts b/server/initializers/migrations/0700-edition-finished-notification.ts
new file mode 100644 (file)
index 0000000..103c0b4
--- /dev/null
@@ -0,0 +1,42 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+  db: any
+}): Promise<void> {
+  const { transaction } = utils
+
+  {
+    const data = {
+      type: Sequelize.INTEGER,
+      defaultValue: null,
+      allowNull: true
+    }
+    await utils.queryInterface.addColumn('userNotificationSetting', 'myVideoEditionFinished', data, { transaction })
+  }
+
+  {
+    const query = 'UPDATE "userNotificationSetting" SET "myVideoEditionFinished" = 1'
+    await utils.sequelize.query(query, { transaction })
+  }
+
+  {
+    const data = {
+      type: Sequelize.INTEGER,
+      defaultValue: null,
+      allowNull: false
+    }
+    await utils.queryInterface.changeColumn('userNotificationSetting', 'myVideoEditionFinished', data, { transaction })
+  }
+}
+
+function down () {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 69b441176b83febb37dac09f1cd6ca9e50c454d1..f480b32cdc86c44af2d6f4dc92dc07499b6221e0 100644 (file)
@@ -11,7 +11,7 @@ import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/l
 import { VideoModel } from '@server/models/video/video'
 import { VideoJobInfoModel } from '@server/models/video/video-job-info'
 import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoWithAllFiles } from '@server/types/models'
-import { MoveObjectStoragePayload, VideoStorage } from '@shared/models'
+import { MoveObjectStoragePayload, VideoState, VideoStorage } from '@shared/models'
 
 const lTagsBase = loggerTagsFactory('move-object-storage')
 
@@ -45,7 +45,7 @@ export async function processMoveToObjectStorage (job: Job) {
     if (pendingMove === 0) {
       logger.info('Running cleanup after moving files to object storage (video %s in job %d)', video.uuid, job.id, lTags)
 
-      await doAfterLastJob(video, payload.isNewVideo)
+      await doAfterLastJob({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo })
     }
   } catch (err) {
     logger.error('Cannot move video %s to object storage.', video.url, { err, ...lTags })
@@ -91,7 +91,13 @@ async function moveHLSFiles (video: MVideoWithAllFiles) {
   }
 }
 
-async function doAfterLastJob (video: MVideoWithAllFiles, isNewVideo: boolean) {
+async function doAfterLastJob (options: {
+  video: MVideoWithAllFiles
+  previousVideoState: VideoState
+  isNewVideo: boolean
+}) {
+  const { video, previousVideoState, isNewVideo } = options
+
   for (const playlist of video.VideoStreamingPlaylists) {
     if (playlist.storage === VideoStorage.OBJECT_STORAGE) continue
 
@@ -115,7 +121,7 @@ async function doAfterLastJob (video: MVideoWithAllFiles, isNewVideo: boolean) {
     await remove(getHLSDirectory(video))
   }
 
-  await moveToNextState(video, isNewVideo)
+  await moveToNextState({ video, previousVideoState, isNewVideo })
 }
 
 async function onFileMoved (options: {
index c5ba0452fb56270d67f5c16f0df8ca391e8f2fdc..d2d2a4f65cbfc60e87b389d1fcf327c64f2417e0 100644 (file)
@@ -8,10 +8,9 @@ import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
 import { generateWebTorrentVideoFilename } from '@server/lib/paths'
 import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
 import { isAbleToUploadVideo } from '@server/lib/user'
-import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video'
+import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
 import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor'
 import { VideoPathManager } from '@server/lib/video-path-manager'
-import { buildNextVideoState } from '@server/lib/video-state'
 import { UserModel } from '@server/models/user/user'
 import { VideoModel } from '@server/models/video/video'
 import { VideoFileModel } from '@server/models/video/video-file'
@@ -33,8 +32,7 @@ import {
   VideoEditorTaskCutPayload,
   VideoEditorTaskIntroPayload,
   VideoEditorTaskOutroPayload,
-  VideoEditorTaskWatermarkPayload,
-  VideoState
+  VideoEditorTaskWatermarkPayload
 } from '@shared/models'
 import { logger, loggerTagsFactory } from '../../../helpers/logger'
 
@@ -42,14 +40,15 @@ const lTagsBase = loggerTagsFactory('video-edition')
 
 async function processVideoEdition (job: Job) {
   const payload = job.data as VideoEditionPayload
+  const lTags = lTagsBase(payload.videoUUID)
 
-  logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
+  logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id, lTags)
 
   const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
 
   // No video, maybe deleted?
   if (!video) {
-    logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
+    logger.info('Can\'t process job %d, video does not exist.', job.id, lTags)
     return undefined
   }
 
@@ -69,7 +68,8 @@ async function processVideoEdition (job: Job) {
         inputPath: tmpInputFilePath ?? originalFilePath,
         video,
         outputPath,
-        task
+        task,
+        lTags
       })
 
       if (tmpInputFilePath) await remove(tmpInputFilePath)
@@ -81,7 +81,7 @@ async function processVideoEdition (job: Job) {
     return outputPath
   })
 
-  logger.info('Video edition ended for video %s.', video.uuid)
+  logger.info('Video edition ended for video %s.', video.uuid, lTags)
 
   const newFile = await buildNewFile(video, editionResultPath)
 
@@ -94,19 +94,13 @@ async function processVideoEdition (job: Job) {
 
   await newFile.save()
 
-  video.state = buildNextVideoState()
   video.duration = await getVideoStreamDuration(outputPath)
   await video.save()
 
   await federateVideoIfNeeded(video, false, undefined)
 
-  if (video.state === VideoState.TO_TRANSCODE) {
-    const user = await UserModel.loadByVideoId(video.id)
-
-    await addOptimizeOrMergeAudioJob(video, newFile, user, false)
-  } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
-    await addMoveToObjectStorageJob(video, false)
-  }
+  const user = await UserModel.loadByVideoId(video.id)
+  await addOptimizeOrMergeAudioJob({ video, videoFile: newFile, user, isNewVideo: false })
 }
 
 // ---------------------------------------------------------------------------
@@ -122,6 +116,7 @@ type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskP
   outputPath: string
   video: MVideo
   task: T
+  lTags: { tags: string[] }
 }
 
 const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
@@ -134,7 +129,7 @@ const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessor
 async function processTask (options: TaskProcessorOptions) {
   const { video, task } = options
 
-  logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
+  logger.info('Processing %s task for video %s.', task.name, video.uuid, { task, ...options.lTags })
 
   const processor = taskProcessors[options.task.name]
   if (!process) throw new Error('Unknown task ' + task.name)
index 6b2d603179e577b3ed193cbf0b79a78f153f272c..110176d81f89d76df36e09f648b96ed0a9aa6e42 100644 (file)
@@ -28,7 +28,7 @@ async function processVideoFileImport (job: Job) {
   await updateVideoFile(video, payload.filePath)
 
   if (CONFIG.OBJECT_STORAGE.ENABLED) {
-    await addMoveToObjectStorageJob(video)
+    await addMoveToObjectStorageJob({ video, previousVideoState: video.state })
   } else {
     await federateVideoIfNeeded(video, false)
   }
index b3ca28c2f01944ce996e7d04728c665aa4a82629..d59a1b12f6a6f5041c038017051d0b1bfb1ac438 100644 (file)
@@ -254,12 +254,12 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
     }
 
     if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
-      return addMoveToObjectStorageJob(videoImportUpdated.Video)
+      return addMoveToObjectStorageJob({ video: videoImportUpdated.Video, previousVideoState: VideoState.TO_IMPORT })
     }
 
     // Create transcoding jobs?
     if (video.state === VideoState.TO_TRANSCODE) {
-      await addOptimizeOrMergeAudioJob(videoImportUpdated.Video, videoFile, videoImport.User)
+      await addOptimizeOrMergeAudioJob({ video: videoImportUpdated.Video, videoFile, user: videoImport.User })
     }
 
   } catch (err) {
index 497f6612a50c8d17e9de358a69f763277f783388..f4de4b47c03e5b4ae76a4e247cc5ad72dc133cba 100644 (file)
@@ -133,7 +133,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
     })
   }
 
-  await moveToNextState(videoWithFiles, false)
+  await moveToNextState({ video: videoWithFiles, isNewVideo: false })
 }
 
 async function cleanupTMPLiveFiles (hlsDirectory: string) {
index 512979734f6e6adc1b9f08442d6c99f4d79d402c..95ee6b384d9902f50a4ca39738e78c3a6b2dfe1f 100644 (file)
@@ -168,7 +168,7 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
   }
 
   await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
-  await retryTransactionWrapper(moveToNextState, video, payload.isNewVideo)
+  await retryTransactionWrapper(moveToNextState, { video, isNewVideo: payload.isNewVideo })
 }
 
 async function onVideoFirstWebTorrentTranscoding (
@@ -210,7 +210,7 @@ async function onVideoFirstWebTorrentTranscoding (
 
   // Move to next state if there are no other resolutions to generate
   if (!hasHls && !hasNewResolutions) {
-    await retryTransactionWrapper(moveToNextState, videoDatabase, payload.isNewVideo)
+    await retryTransactionWrapper(moveToNextState, { video: videoDatabase, isNewVideo: payload.isNewVideo })
   }
 }
 
@@ -225,7 +225,7 @@ async function onNewWebTorrentFileResolution (
 
   await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
 
-  await retryTransactionWrapper(moveToNextState, video, payload.isNewVideo)
+  await retryTransactionWrapper(moveToNextState, { video, isNewVideo: payload.isNewVideo })
 }
 
 // ---------------------------------------------------------------------------
index 8b68d2e691da14544e457a3a25442da3fe34aa27..e34a82603f08001c9f69c874af7f17a11fcd1ab2 100644 (file)
@@ -12,6 +12,7 @@ import {
   AbuseStateChangeForReporter,
   AutoFollowForInstance,
   CommentMention,
+  EditionFinishedForOwner,
   FollowForInstance,
   FollowForUser,
   ImportFinishedForOwner,
@@ -53,7 +54,8 @@ class Notifier {
     abuseStateChange: [ AbuseStateChangeForReporter ],
     newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
     newPeertubeVersion: [ NewPeerTubeVersionForAdmins ],
-    newPluginVersion: [ NewPluginVersionForAdmins ]
+    newPluginVersion: [ NewPluginVersionForAdmins ],
+    videoEditionFinished: [ EditionFinishedForOwner ]
   }
 
   private static instance: Notifier
@@ -198,6 +200,13 @@ class Notifier {
       .catch(err => logger.error('Cannot notify on new plugin version %s.', plugin.name, { err }))
   }
 
+  notifyOfFinishedVideoEdition (video: MVideoFullLight) {
+    const models = this.notificationModels.videoEditionFinished
+
+    this.sendNotifications(models, video)
+      .catch(err => logger.error('Cannot notify on finished edition %s.', video.url, { err }))
+  }
+
   private async notify <T> (object: AbstractNotification<T>) {
     await object.prepare()
 
index fd06e080d34841144eb3336289f472ac94ac501e..37435f898f99ce9dadc2f193e7e28cf0dfde0ec8 100644 (file)
@@ -46,7 +46,7 @@ export abstract class AbstractOwnedVideoPublication extends AbstractNotification
       subject: `Your video ${this.payload.name} has been published`,
       text: `Your video "${this.payload.name}" has been published.`,
       locals: {
-        title: 'You video is live',
+        title: 'Your video is live',
         action: {
           text: 'View video',
           url: videoUrl
diff --git a/server/lib/notifier/shared/video-publication/edition-finished-for-owner.ts b/server/lib/notifier/shared/video-publication/edition-finished-for-owner.ts
new file mode 100644 (file)
index 0000000..dec91f5
--- /dev/null
@@ -0,0 +1,57 @@
+import { logger } from '@server/helpers/logger'
+import { WEBSERVER } from '@server/initializers/constants'
+import { UserModel } from '@server/models/user/user'
+import { UserNotificationModel } from '@server/models/user/user-notification'
+import { MUserDefault, MUserWithNotificationSetting, MVideoFullLight, UserNotificationModelForApi } from '@server/types/models'
+import { UserNotificationType } from '@shared/models'
+import { AbstractNotification } from '../common/abstract-notification'
+
+export class EditionFinishedForOwner extends AbstractNotification <MVideoFullLight> {
+  private user: MUserDefault
+
+  async prepare () {
+    this.user = await UserModel.loadByVideoId(this.payload.id)
+  }
+
+  log () {
+    logger.info('Notifying user %s its video edition %s is finished.', this.user.username, this.payload.url)
+  }
+
+  getSetting (user: MUserWithNotificationSetting) {
+    return user.NotificationSetting.myVideoEditionFinished
+  }
+
+  getTargetUsers () {
+    if (!this.user) return []
+
+    return [ this.user ]
+  }
+
+  async createNotification (user: MUserWithNotificationSetting) {
+    const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
+      type: UserNotificationType.MY_VIDEO_EDITION_FINISHED,
+      userId: user.id,
+      videoId: this.payload.id
+    })
+    notification.Video = this.payload
+
+    return notification
+  }
+
+  createEmail (to: string) {
+    const videoUrl = WEBSERVER.URL + this.payload.getWatchStaticPath()
+
+    return {
+      to,
+      subject: `Edition of your video ${this.payload.name} has finished`,
+      text: `Edition of your video ${this.payload.name} has finished.`,
+      locals: {
+        title: 'Video edition has finished',
+        action: {
+          text: 'View video',
+          url: videoUrl
+        }
+      }
+    }
+  }
+}
index 9407745044a1d1c1b5dd5b4731093a70a5d8add2..57f3443b944031b07caaf957d4349b2cb91e5565 100644 (file)
@@ -1,4 +1,5 @@
 export * from './new-video-for-subscribers'
+export * from './edition-finished-for-owner'
 export * from './import-finished-for-owner'
 export * from './owned-publication-after-auto-unblacklist'
 export * from './owned-publication-after-schedule-update'
index ea755f4beab4dee1a92bb863224a9939126ab492..173d89d0bbb5143ea858bc5cf1b97af1c73f0e51 100644 (file)
@@ -252,7 +252,8 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
     abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     autoInstanceFollowing: UserNotificationSettingValue.WEB,
     newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
-    newPluginVersion: UserNotificationSettingValue.WEB
+    newPluginVersion: UserNotificationSettingValue.WEB,
+    myVideoEditionFinished: UserNotificationSettingValue.WEB
   }
 
   return UserNotificationSettingModel.create(values, { transaction: t })
index 97ff540edce61f9db211a74f914dc3d938b1cdda..f75f8170457ea942ca0bed8df473c8853e2990f9 100644 (file)
@@ -16,6 +16,7 @@ function buildNextVideoState (currentState?: VideoState) {
   }
 
   if (
+    currentState !== VideoState.TO_EDIT &&
     currentState !== VideoState.TO_TRANSCODE &&
     currentState !== VideoState.TO_MOVE_TO_EXTERNAL_STORAGE &&
     CONFIG.TRANSCODING.ENABLED
@@ -33,7 +34,13 @@ function buildNextVideoState (currentState?: VideoState) {
   return VideoState.PUBLISHED
 }
 
-function moveToNextState (video: MVideoUUID, isNewVideo = true) {
+function moveToNextState (options: {
+  video: MVideoUUID
+  previousVideoState?: VideoState
+  isNewVideo?: boolean // Default true
+}) {
+  const { video, previousVideoState, isNewVideo = true } = options
+
   return sequelizeTypescript.transaction(async t => {
     // Maybe the video changed in database, refresh it
     const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
@@ -48,28 +55,35 @@ function moveToNextState (video: MVideoUUID, isNewVideo = true) {
     const newState = buildNextVideoState(videoDatabase.state)
 
     if (newState === VideoState.PUBLISHED) {
-      return moveToPublishedState(videoDatabase, isNewVideo, t)
+      return moveToPublishedState({ video: videoDatabase, previousVideoState, isNewVideo, transaction: t })
     }
 
     if (newState === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
-      return moveToExternalStorageState(videoDatabase, isNewVideo, t)
+      return moveToExternalStorageState({ video: videoDatabase, isNewVideo, transaction: t })
     }
   })
 }
 
-async function moveToExternalStorageState (video: MVideoFullLight, isNewVideo: boolean, transaction: Transaction) {
+async function moveToExternalStorageState (options: {
+  video: MVideoFullLight
+  isNewVideo: boolean
+  transaction: Transaction
+}) {
+  const { video, isNewVideo, transaction } = options
+
   const videoJobInfo = await VideoJobInfoModel.load(video.id, transaction)
   const pendingTranscode = videoJobInfo?.pendingTranscode || 0
 
   // We want to wait all transcoding jobs before moving the video on an external storage
   if (pendingTranscode !== 0) return false
 
+  const previousVideoState = video.state
   await video.setNewState(VideoState.TO_MOVE_TO_EXTERNAL_STORAGE, isNewVideo, transaction)
 
   logger.info('Creating external storage move job for video %s.', video.uuid, { tags: [ video.uuid ] })
 
   try {
-    await addMoveToObjectStorageJob(video, isNewVideo)
+    await addMoveToObjectStorageJob({ video, previousVideoState, isNewVideo })
 
     return true
   } catch (err) {
@@ -103,21 +117,33 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function moveToPublishedState (video: MVideoFullLight, isNewVideo: boolean, transaction: Transaction) {
-  logger.info('Publishing video %s.', video.uuid, { tags: [ video.uuid ] })
+async function moveToPublishedState (options: {
+  video: MVideoFullLight
+  isNewVideo: boolean
+  transaction: Transaction
+  previousVideoState?: VideoState
+}) {
+  const { video, isNewVideo, transaction, previousVideoState } = options
+  const previousState = previousVideoState ?? video.state
+
+  logger.info('Publishing video %s.', video.uuid, { previousState, tags: [ video.uuid ] })
 
-  const previousState = video.state
   await video.setNewState(VideoState.PUBLISHED, isNewVideo, transaction)
 
   // If the video was not published, we consider it is a new one for other instances
   // Live videos are always federated, so it's not a new video
   await federateVideoIfNeeded(video, isNewVideo, transaction)
 
-  if (!isNewVideo) return
+  if (previousState === VideoState.TO_EDIT) {
+    Notifier.Instance.notifyOfFinishedVideoEdition(video)
+    return
+  }
 
-  Notifier.Instance.notifyOnNewVideoIfNeeded(video)
+  if (isNewVideo) {
+    Notifier.Instance.notifyOnNewVideoIfNeeded(video)
 
-  if (previousState === VideoState.TO_TRANSCODE) {
-    Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(video)
+    if (previousState === VideoState.TO_TRANSCODE) {
+      Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(video)
+    }
   }
 }
index ec4256c1aa077b0efabb373c32f30badab9feb95..a98e45c604d6482f637e97c3b193c30fa0661d01 100644 (file)
@@ -6,7 +6,7 @@ import { VideoModel } from '@server/models/video/video'
 import { VideoJobInfoModel } from '@server/models/video/video-job-info'
 import { FilteredModelAttributes } from '@server/types'
 import { MThumbnail, MUserId, MVideoFile, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models'
-import { ThumbnailType, VideoCreate, VideoPrivacy, VideoTranscodingPayload } from '@shared/models'
+import { ThumbnailType, VideoCreate, VideoPrivacy, VideoState, VideoTranscodingPayload } from '@shared/models'
 import { CreateJobOptions, JobQueue } from './job-queue/job-queue'
 import { updateVideoMiniatureFromExisting } from './thumbnail'
 import { CONFIG } from '@server/initializers/config'
@@ -67,6 +67,8 @@ async function buildVideoThumbnailsFromReq (options: {
   return Promise.all(promises)
 }
 
+// ---------------------------------------------------------------------------
+
 async function setVideoTags (options: {
   video: MVideoTag
   tags: string[]
@@ -81,7 +83,16 @@ async function setVideoTags (options: {
   video.Tags = tagInstances
 }
 
-async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId, isNewVideo = true) {
+// ---------------------------------------------------------------------------
+
+async function addOptimizeOrMergeAudioJob (options: {
+  video: MVideoUUID
+  videoFile: MVideoFile
+  user: MUserId
+  isNewVideo?: boolean // Default true
+}) {
+  const { video, videoFile, user, isNewVideo } = options
+
   let dataInput: VideoTranscodingPayload
 
   if (videoFile.isAudio()) {
@@ -113,13 +124,6 @@ async function addTranscodingJob (payload: VideoTranscodingPayload, options: Cre
   return JobQueue.Instance.createJobWithPromise({ type: 'video-transcoding', payload: payload }, options)
 }
 
-async function addMoveToObjectStorageJob (video: MVideoUUID, isNewVideo = true) {
-  await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingMove')
-
-  const dataInput = { videoUUID: video.uuid, isNewVideo }
-  return JobQueue.Instance.createJobWithPromise({ type: 'move-to-object-storage', payload: dataInput })
-}
-
 async function getTranscodingJobPriority (user: MUserId) {
   const now = new Date()
   const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
@@ -131,6 +135,21 @@ async function getTranscodingJobPriority (user: MUserId) {
 
 // ---------------------------------------------------------------------------
 
+async function addMoveToObjectStorageJob (options: {
+  video: MVideoUUID
+  previousVideoState: VideoState
+  isNewVideo?: boolean // Default true
+}) {
+  const { video, previousVideoState, isNewVideo = true } = options
+
+  await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingMove')
+
+  const dataInput = { videoUUID: video.uuid, isNewVideo, previousVideoState }
+  return JobQueue.Instance.createJobWithPromise({ type: 'move-to-object-storage', payload: dataInput })
+}
+
+// ---------------------------------------------------------------------------
+
 export {
   buildLocalVideoFromReq,
   buildVideoThumbnailsFromReq,
index f03b19e41472d66d007ca8cda7493d71777e30a2..b144f837765436b3e4c1ffa00f77603f462bd629 100644 (file)
@@ -175,6 +175,15 @@ export class UserNotificationSettingModel extends Model<Partial<AttributesOnly<U
   @Column
   newPluginVersion: UserNotificationSettingValue
 
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingMyVideoEditionFinished',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoEditionFinished')
+  )
+  @Column
+  myVideoEditionFinished: UserNotificationSettingValue
+
   @ForeignKey(() => UserModel)
   @Column
   userId: number
@@ -216,6 +225,7 @@ export class UserNotificationSettingModel extends Model<Partial<AttributesOnly<U
       abuseNewMessage: this.abuseNewMessage,
       abuseStateChange: this.abuseStateChange,
       newPeerTubeVersion: this.newPeerTubeVersion,
+      myVideoEditionFinished: this.myVideoEditionFinished,
       newPluginVersion: this.newPluginVersion
     }
   }
index 4bc8084a1de5a1be7ff5740d85793ce1f97fb78e..93355e8b3782cba6fac9e58269e075425b5088ed 100644 (file)
@@ -171,6 +171,7 @@ describe('Test user notifications API validators', function () {
       abuseNewMessage: UserNotificationSettingValue.WEB,
       abuseStateChange: UserNotificationSettingValue.WEB,
       newPeerTubeVersion: UserNotificationSettingValue.WEB,
+      myVideoEditionFinished: UserNotificationSettingValue.WEB,
       newPluginVersion: UserNotificationSettingValue.WEB
     }
 
index f9f3e0e0ea76f2e4729630a52bd8d3731e044d19..c87686cb5c64297e4fac0071b9012b095401683e 100644 (file)
@@ -7,6 +7,7 @@ import {
   checkMyVideoImportIsFinished,
   checkNewActorFollow,
   checkNewVideoFromSubscription,
+  checkVideoEditionIsFinished,
   checkVideoIsPublished,
   FIXTURE_URLS,
   MockSmtpServer,
@@ -15,7 +16,7 @@ import {
 } from '@server/tests/shared'
 import { wait } from '@shared/core-utils'
 import { buildUUID } from '@shared/extra-utils'
-import { UserNotification, UserNotificationType, VideoPrivacy } from '@shared/models'
+import { UserNotification, UserNotificationType, VideoEditorTask, VideoPrivacy } from '@shared/models'
 import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands'
 
 const expect = chai.expect
@@ -23,10 +24,12 @@ const expect = chai.expect
 describe('Test user notifications', function () {
   let servers: PeerTubeServer[] = []
   let userAccessToken: string
+
   let userNotifications: UserNotification[] = []
   let adminNotifications: UserNotification[] = []
   let adminNotificationsServer2: UserNotification[] = []
   let emails: object[] = []
+
   let channelId: number
 
   before(async function () {
@@ -320,6 +323,42 @@ describe('Test user notifications', function () {
     })
   })
 
+  describe('Video editor', function () {
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[1],
+        emails,
+        socketNotifications: adminNotificationsServer2,
+        token: servers[1].accessToken
+      }
+    })
+
+    it('Should send a notification after editor edition', async function () {
+      this.timeout(240000)
+
+      const { name, shortUUID, id } = await uploadRandomVideoOnServers(servers, 2, { waitTranscoding: true })
+
+      await waitJobs(servers)
+      await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' })
+
+      const tasks: VideoEditorTask[] = [
+        {
+          name: 'cut',
+          options: {
+            start: 0,
+            end: 1
+          }
+        }
+      ]
+      await servers[1].videoEditor.createEditionTasks({ videoId: id, tasks })
+      await waitJobs(servers)
+
+      await checkVideoEditionIsFinished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' })
+    })
+  })
+
   describe('My video is imported', function () {
     let baseParams: CheckerBaseParams
 
index a9b6950cc367ed105e415d555e2d1b9cf41fc5f8..f70bd49e6bfe21d1fada478327f47869861a281c 100644 (file)
@@ -56,13 +56,7 @@ describe('Test video editor', function () {
 
     await servers[0].config.enableMinimumTranscoding()
 
-    await servers[0].config.updateExistingSubConfig({
-      newConfig: {
-        videoEditor: {
-          enabled: true
-        }
-      }
-    })
+    await servers[0].config.enableEditor()
   })
 
   describe('Cutting', function () {
index 78d3787f06b9d5c76454927479f8803594850128..f1ddbbbf74de4ead7d01a75e777c77edef54ea57 100644 (file)
@@ -47,6 +47,7 @@ function getAllNotificationsSettings (): UserNotificationSetting {
     abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+    myVideoEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
   }
 }
@@ -109,6 +110,34 @@ async function checkVideoIsPublished (options: CheckerBaseParams & {
   await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
 }
 
+async function checkVideoEditionIsFinished (options: CheckerBaseParams & {
+  videoName: string
+  shortUUID: string
+  checkType: CheckerType
+}) {
+  const { videoName, shortUUID } = options
+  const notificationType = UserNotificationType.MY_VIDEO_EDITION_FINISHED
+
+  function notificationChecker (notification: UserNotification, checkType: CheckerType) {
+    if (checkType === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      checkVideo(notification.video, videoName, shortUUID)
+      checkActor(notification.video.channel)
+    } else {
+      expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
+    }
+  }
+
+  function emailNotificationFinder (email: object) {
+    const text: string = email['text']
+    return text.includes(shortUUID) && text.includes('Edition of your video')
+  }
+
+  await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
+}
+
 async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
   videoName: string
   shortUUID: string
@@ -656,6 +685,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
   await setDefaultChannelAvatar(servers)
   await setDefaultAccountAvatar(servers)
 
+  await servers[1].config.enableEditor()
+
   if (serversCount > 1) {
     await doubleFollow(servers[0], servers[1])
   }
@@ -724,7 +755,8 @@ export {
   checkNewCommentAbuseForModerators,
   checkNewAccountAbuseForModerators,
   checkNewPeerTubeVersion,
-  checkNewPluginVersion
+  checkNewPluginVersion,
+  checkVideoEditionIsFinished
 }
 
 // ---------------------------------------------------------------------------
index d81b7269635bea6a47412769ae12dacebe7b2640..3b4855eaa23ee02fc61736f4262fc9b20b38cdce 100644 (file)
@@ -1,4 +1,5 @@
 import { ContextType } from '../activitypub/context'
+import { VideoState } from '../videos'
 import { VideoEditorTaskCut } from '../videos/editor'
 import { VideoResolution } from '../videos/file/video-resolution.enum'
 import { SendEmailOptions } from './emailer.model'
@@ -116,6 +117,9 @@ export type ManageVideoTorrentPayload =
 interface BaseTranscodingPayload {
   videoUUID: string
   isNewVideo?: boolean
+
+  // Custom notification when the task is finished
+  notification?: 'default' | 'video-edition'
 }
 
 export interface HLSTranscodingPayload extends BaseTranscodingPayload {
@@ -171,6 +175,7 @@ export interface DeleteResumableUploadMetaFilePayload {
 export interface MoveObjectStoragePayload {
   videoUUID: string
   isNewVideo: boolean
+  previousVideoState: VideoState
 }
 
 export type VideoEditorTaskCutPayload = VideoEditorTaskCut
index 977e6b9858e18f34cc79af692657a16dbd2c6563..35656f14c91130429c2fa8e1a519c1ccca1e03fd 100644 (file)
@@ -27,4 +27,6 @@ export interface UserNotificationSetting {
 
   newPeerTubeVersion: UserNotificationSettingValue
   newPluginVersion: UserNotificationSettingValue
+
+  myVideoEditionFinished: UserNotificationSettingValue
 }
index a2621fb5bc7c050712baba91688e072f23b11320..a2918194fcaa7c228e82ef3233d90d07d3d0defc 100644 (file)
@@ -30,7 +30,9 @@ export const enum UserNotificationType {
   ABUSE_NEW_MESSAGE = 16,
 
   NEW_PLUGIN_VERSION = 17,
-  NEW_PEERTUBE_VERSION = 18
+  NEW_PEERTUBE_VERSION = 18,
+
+  MY_VIDEO_EDITION_FINISHED = 19
 }
 
 export interface VideoInfo {
index 1dd6e1ea4bda766180007841b5c0224305bb5d93..35a1eec7c286587da22d0dfc302376ca7546253d 100644 (file)
@@ -111,6 +111,16 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
+  enableEditor () {
+    return this.updateExistingSubConfig({
+      newConfig: {
+        videoEditor: {
+          enabled: true
+        }
+      }
+    })
+  }
+
   getConfig (options: OverrideCommandOptions = {}) {
     const path = '/api/v1/config'