Add ability to update a video channel
authorChocobozzz <me@florianbigard.com>
Fri, 11 May 2018 13:10:13 +0000 (15:10 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 11 May 2018 13:25:51 +0000 (15:25 +0200)
26 files changed:
client/src/app/shared/actor/actor.model.ts
client/src/app/shared/video/video-edit.model.ts
client/src/app/shared/video/video.model.ts
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-edit/shared/video-edit.component.html
client/src/app/videos/+video-edit/shared/video-edit.component.scss
client/src/app/videos/+video-edit/shared/video-edit.component.ts
client/src/app/videos/+video-edit/video-add.component.ts
client/src/app/videos/+video-edit/video-update.component.ts
server/controllers/api/videos/index.ts
server/helpers/custom-validators/videos.ts
server/lib/activitypub/actor.ts
server/lib/activitypub/process/process-undo.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/send/send-undo.ts
server/lib/activitypub/share.ts
server/lib/activitypub/videos.ts
server/middlewares/validators/videos.ts
server/models/video/video-share.ts
server/models/video/video.ts
server/tests/api/videos/video-channels.ts
server/tests/utils/videos/videos.ts
shared/models/activitypub/activity.ts
shared/models/actors/actor.model.ts
shared/models/videos/video-update.model.ts
shared/models/videos/video.model.ts

index 56ff780b78d10272ca4460dd3c5dc455fcdd76aa..37d84cb6ea275a20f10f4ef594a475c77e518fb1 100644 (file)
@@ -1,6 +1,6 @@
 import { Actor as ActorServer } from '../../../../../shared/models/actors/actor.model'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
 import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
+import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
 
 export abstract class Actor implements ActorServer {
   id: number
@@ -41,8 +41,8 @@ export abstract class Actor implements ActorServer {
     this.host = hash.host
     this.followingCount = hash.followingCount
     this.followersCount = hash.followersCount
-    this.createdAt = new Date(hash.createdAt.toString())
-    this.updatedAt = new Date(hash.updatedAt.toString())
+    this.createdAt = new Date(hash.createdAt)
+    this.updatedAt = new Date(hash.updatedAt)
     this.avatar = hash.avatar
 
     this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this)
index 39826d71e2d2791e71716eec0c7b3563c6c6eaa2..ad2929db5f194a47f739f407d2198e33d07c49d0 100644 (file)
@@ -10,7 +10,7 @@ export class VideoEdit {
   tags: string[]
   nsfw: boolean
   commentsEnabled: boolean
-  channel: number
+  channelId: number
   privacy: VideoPrivacy
   support: string
   thumbnailfile?: any
@@ -32,7 +32,7 @@ export class VideoEdit {
       this.tags = videoDetails.tags
       this.nsfw = videoDetails.nsfw
       this.commentsEnabled = videoDetails.commentsEnabled
-      this.channel = videoDetails.channel.id
+      this.channelId = videoDetails.channel.id
       this.privacy = videoDetails.privacy.id
       this.support = videoDetails.support
       this.thumbnailUrl = videoDetails.thumbnailUrl
@@ -57,7 +57,7 @@ export class VideoEdit {
       tags: this.tags,
       nsfw: this.nsfw,
       commentsEnabled: this.commentsEnabled,
-      channelId: this.channel,
+      channelId: this.channelId,
       privacy: this.privacy
     }
   }
index f56eecaebef2bdd9eb1d700ba7d3b134f16cca30..48d562f9c863fe8d88ce8423b7951f29d6726e21 100644 (file)
@@ -45,6 +45,16 @@ export class Video implements VideoServerModel {
     avatar: Avatar
   }
 
+  channel: {
+    id: number
+    uuid: string
+    name: string
+    displayName: string
+    url: string
+    host: string
+    avatar: Avatar
+  }
+
   private static createDurationString (duration: number) {
     const hours = Math.floor(duration / 3600)
     const minutes = Math.floor(duration % 3600 / 60)
index b45777c55569d8838864181a1b855452ba2f03a7..cd8539b414af119fcd9d36f7a6189f451262b257 100644 (file)
@@ -67,6 +67,7 @@ export class VideoService {
       language,
       support,
       description,
+      channelId: video.channelId,
       privacy: video.privacy,
       tags: video.tags,
       nsfw: video.nsfw,
index 9cd3454a08a3de65a2fdc0d39d922bed8156792c..77b554ad5daedee67f1a5b9a250b24165921ac4e 100644 (file)
@@ -33,7 +33,7 @@
       <div class="col-md-4">
         <div class="form-group">
           <label>Channel</label>
-          <div class="peertube-select-disabled-container">
+          <div class="peertube-select-container">
             <select formControlName="channelId">
               <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
             </select>
index cf64ff589cac093ee0fa18605f95e7fd22544749..58ed5ab986981f4879fdf2fab6a4460ac06265e7 100644 (file)
@@ -5,10 +5,6 @@
   @include peertube-select-container(auto);
 }
 
-.peertube-select-disabled-container {
-  @include peertube-select-disabled-container(auto);
-}
-
 .form-group-checkbox {
   my-help { margin-left: 5px }
 }
index 6ab1a4a2411c2245354d0e5403b8fd0523c04345..77e9848551e5877b9d4eac070c91909cb31dcb69 100644 (file)
@@ -75,7 +75,7 @@ export class VideoEditComponent implements OnInit {
 
     this.form.addControl('name', new FormControl('', VIDEO_NAME.VALIDATORS))
     this.form.addControl('privacy', new FormControl('', VIDEO_PRIVACY.VALIDATORS))
-    this.form.addControl('channelId', new FormControl({ value: '', disabled: true }))
+    this.form.addControl('channelId', new FormControl('', VIDEO_CHANNEL.VALIDATORS))
     this.form.addControl('nsfw', new FormControl(false))
     this.form.addControl('commentsEnabled', new FormControl(true))
     this.form.addControl('category', new FormControl('', VIDEO_CATEGORY.VALIDATORS))
index ae5548897deb11693de9a5e8ac74cda19c5c2121..fa967018df419bcaaee16cb7cda726fc67fe93ca 100644 (file)
@@ -220,7 +220,7 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
 
     const video = new VideoEdit()
     video.patch(this.form.value)
-    video.channel = this.firstStepChannelId
+    video.channelId = this.firstStepChannelId
     video.id = this.videoUploadedIds.id
     video.uuid = this.videoUploadedIds.uuid
 
index 6cd204f7252e9bb387963e939c5ffe9d10f8ca13..73e2764c6692a01bfc6c7c6b622edcb52c88e13e 100644 (file)
@@ -9,9 +9,9 @@ import { ServerService } from '../../core'
 import { AuthService } from '../../core/auth'
 import { FormReactive } from '../../shared'
 import { ValidatorMessage } from '../../shared/forms/form-validators/validator-message'
-import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
 import { VideoEdit } from '../../shared/video/video-edit.model'
 import { VideoService } from '../../shared/video/video.service'
+import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
 
 @Component({
   selector: 'my-videos-update',
@@ -64,12 +64,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
         video => {
           this.video = new VideoEdit(video)
 
-          this.userVideoChannels = [
-            {
-              id: video.channel.id,
-              label: video.channel.displayName
-            }
-          ]
+          populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+            .catch(err => console.error(err))
 
           // We cannot set private a video that was not private
           if (video.privacy.id !== VideoPrivacy.PRIVATE) {
index 4b3198a749033b60e4eb356d1d2c2095a5c44dc5..c07430e6c85589a1c71de45f4d05853dbc63f3bb 100644 (file)
@@ -19,7 +19,12 @@ import {
   VIDEO_MIMETYPE_EXT,
   VIDEO_PRIVACIES
 } from '../../../initializers'
-import { fetchRemoteVideoDescription, getVideoActivityPubUrl, shareVideoByServerAndChannel } from '../../../lib/activitypub'
+import {
+  changeVideoChannelShare,
+  fetchRemoteVideoDescription,
+  getVideoActivityPubUrl,
+  shareVideoByServerAndChannel
+} from '../../../lib/activitypub'
 import { sendCreateVideo, sendCreateView, sendUpdateVideo } from '../../../lib/activitypub/send'
 import { JobQueue } from '../../../lib/job-queue'
 import { Redis } from '../../../lib/redis'
@@ -305,6 +310,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
       const sequelizeOptions = {
         transaction: t
       }
+      const oldVideoChannel = videoInstance.VideoChannel
 
       if (videoInfoToUpdate.name !== undefined) videoInstance.set('name', videoInfoToUpdate.name)
       if (videoInfoToUpdate.category !== undefined) videoInstance.set('category', videoInfoToUpdate.category)
@@ -325,18 +331,25 @@ async function updateVideo (req: express.Request, res: express.Response) {
 
       const videoInstanceUpdated = await videoInstance.save(sequelizeOptions)
 
+      // Video tags update?
       if (videoInfoToUpdate.tags) {
         const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)
 
-        await videoInstance.$set('Tags', tagInstances, sequelizeOptions)
-        videoInstance.Tags = tagInstances
+        await videoInstanceUpdated.$set('Tags', tagInstances, sequelizeOptions)
+        videoInstanceUpdated.Tags = tagInstances
       }
 
-      // Now we'll update the video's meta data to our friends
-      if (wasPrivateVideo === false) {
-        await sendUpdateVideo(videoInstanceUpdated, t)
+      // Video channel update?
+      if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
+        await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel)
+        videoInstance.VideoChannel = res.locals.videoChannel
+
+        if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
       }
 
+      // Now we'll update the video's meta data to our friends
+      if (wasPrivateVideo === false) await sendUpdateVideo(videoInstanceUpdated, t)
+
       // Video is not private anymore, send a create action to remote servers
       if (wasPrivateVideo === true && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE) {
         await sendCreateVideo(videoInstanceUpdated, t)
index 23d2d8ac6aa380af3e4c2f9818563a31e03b38f6..c35db49ac426dee63418b352d437a4af2d543e04 100644 (file)
@@ -14,6 +14,7 @@ import {
 } from '../../initializers'
 import { VideoModel } from '../../models/video/video'
 import { exists, isArray, isFileValid } from './misc'
+import { VideoChannelModel } from '../../models/video/video-channel'
 
 const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
 const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES
@@ -124,6 +125,20 @@ async function isVideoExist (id: string, res: Response) {
   return true
 }
 
+async function isVideoChannelOfAccountExist (channelId: number, accountId: number, res: Response) {
+  const videoChannel = await VideoChannelModel.loadByIdAndAccount(channelId, accountId)
+  if (!videoChannel) {
+    res.status(400)
+       .json({ error: 'Unknown video video channel for this account.' })
+       .end()
+
+    return false
+  }
+
+  res.locals.videoChannel = videoChannel
+  return true
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -146,5 +161,6 @@ export {
   isVideoFileSizeValid,
   isVideoExist,
   isVideoImage,
+  isVideoChannelOfAccountExist,
   isVideoSupportValid
 }
index b0cf9bb17b0c31652afad0b150add4e52f6474b1..5773fc34fc9d5c6987b18dda58deebab139a198e 100644 (file)
@@ -353,7 +353,7 @@ async function saveVideoChannel (actor: ActorModel, result: FetchRemoteActorResu
   return videoChannelCreated
 }
 
-async function refreshActorIfNeeded (actor: ActorModel) {
+async function refreshActorIfNeeded (actor: ActorModel): Promise<ActorModel> {
   if (!actor.isOutdated()) return actor
 
   try {
index 565e7028983ef265db30f71da6a684424713718a..9b024d15f5c2cd802eb463ebb3a9f1d731ef0a8a 100644 (file)
@@ -1,4 +1,4 @@
-import { ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub'
+import { ActivityAnnounce, ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub'
 import { DislikeObject } from '../../../../shared/models/activitypub/objects'
 import { getActorUrl } from '../../../helpers/activitypub'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
@@ -10,6 +10,7 @@ import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { forwardActivity } from '../send/misc'
 import { getOrCreateAccountAndVideoAndChannel } from '../videos'
+import { VideoShareModel } from '../../../models/video/video-share'
 
 async function processUndoActivity (activity: ActivityUndo) {
   const activityToUndo = activity.object
@@ -22,6 +23,8 @@ async function processUndoActivity (activity: ActivityUndo) {
     return processUndoDislike(actorUrl, activity)
   } else if (activityToUndo.type === 'Follow') {
     return processUndoFollow(actorUrl, activityToUndo)
+  } else if (activityToUndo.type === 'Announce') {
+    return processUndoAnnounce(actorUrl, activityToUndo)
   }
 
   logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
@@ -123,3 +126,23 @@ function undoFollow (actorUrl: string, followActivity: ActivityFollow) {
     return undefined
   })
 }
+
+function processUndoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) {
+  const options = {
+    arguments: [ actorUrl, announceActivity ],
+    errorMessage: 'Cannot undo announce with many retries.'
+  }
+
+  return retryTransactionWrapper(undoAnnounce, options)
+}
+
+function undoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) {
+  return sequelizeTypescript.transaction(async t => {
+    const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
+    if (!share) throw new Error(`'Unknown video share ${announceActivity.id}.`)
+
+    await share.destroy({ transaction: t })
+
+    return undefined
+  })
+}
index 51e3cc4e3b7127d22efa0b4b1cf3718ffff4699d..0dd657c2b2d26fee8478d7d4a839dc6fcca12438 100644 (file)
@@ -14,7 +14,7 @@ import { VideoFileModel } from '../../../models/video/video-file'
 import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor'
 import {
   generateThumbnailFromUrl,
-  getOrCreateAccountAndVideoAndChannel,
+  getOrCreateAccountAndVideoAndChannel, getOrCreateVideoChannel,
   videoActivityObjectToDBAttributes,
   videoFileActivityUrlToDBAttributes
 } from '../videos'
@@ -54,6 +54,10 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) {
 
   const res = await getOrCreateAccountAndVideoAndChannel(videoAttributesToUpdate.id)
 
+  // Fetch video channel outside the transaction
+  const newVideoChannelActor = await getOrCreateVideoChannel(videoAttributesToUpdate)
+  const newVideoChannel = newVideoChannelActor.VideoChannel
+
   logger.debug('Updating remote video "%s".', videoAttributesToUpdate.uuid)
   let videoInstance = res.video
   let videoFieldsSave: any
@@ -66,12 +70,13 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) {
 
       videoFieldsSave = videoInstance.toJSON()
 
+      // Check actor has the right to update the video
       const videoChannel = videoInstance.VideoChannel
       if (videoChannel.Account.Actor.id !== actor.id) {
         throw new Error('Account ' + actor.url + ' does not own video channel ' + videoChannel.Actor.url)
       }
 
-      const videoData = await videoActivityObjectToDBAttributes(videoChannel, videoAttributesToUpdate, activity.to)
+      const videoData = await videoActivityObjectToDBAttributes(newVideoChannel, videoAttributesToUpdate, activity.to)
       videoInstance.set('name', videoData.name)
       videoInstance.set('uuid', videoData.uuid)
       videoInstance.set('url', videoData.url)
@@ -87,6 +92,7 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) {
       videoInstance.set('updatedAt', videoData.updatedAt)
       videoInstance.set('views', videoData.views)
       videoInstance.set('privacy', videoData.privacy)
+      videoInstance.set('channelId', videoData.channelId)
 
       await videoInstance.save(sequelizeOptions)
 
index bd49d452e4e229b877767063557757531520c075..adee2192fad735092810f61778529c9c02b75112 100644 (file)
@@ -1,5 +1,12 @@
 import { Transaction } from 'sequelize'
-import { ActivityAudience, ActivityCreate, ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub'
+import {
+  ActivityAnnounce,
+  ActivityAudience,
+  ActivityCreate,
+  ActivityFollow,
+  ActivityLike,
+  ActivityUndo
+} from '../../../../shared/models/activitypub'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { VideoModel } from '../../../models/video/video'
@@ -16,6 +23,8 @@ import {
 import { createActivityData, createDislikeActivityData } from './send-create'
 import { followActivityData } from './send-follow'
 import { likeActivityData } from './send-like'
+import { VideoShareModel } from '../../../models/video/video-share'
+import { buildVideoAnnounce } from './send-announce'
 
 async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) {
   const me = actorFollow.ActorFollower
@@ -58,7 +67,7 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans
 
   const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
   const dislikeActivity = createDislikeActivityData(byActor, video)
-  const object = await createActivityData(undoUrl, byActor, dislikeActivity, t)
+  const object = await createActivityData(dislikeUrl, byActor, dislikeActivity, t)
 
   if (video.isOwned() === false) {
     const audience = getOriginVideoAudience(video, actorsInvolvedInVideo)
@@ -73,12 +82,24 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans
   return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException)
 }
 
+async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
+  const undoUrl = getUndoActivityPubUrl(videoShare.url)
+
+  const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
+  const object = await buildVideoAnnounce(byActor, videoShare, video, t)
+  const data = await undoActivityData(undoUrl, byActor, object, t)
+
+  const followersException = [ byActor ]
+  return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   sendUndoFollow,
   sendUndoLike,
-  sendUndoDislike
+  sendUndoDislike,
+  sendUndoAnnounce
 }
 
 // ---------------------------------------------------------------------------
@@ -86,7 +107,7 @@ export {
 async function undoActivityData (
   url: string,
   byActor: ActorModel,
-  object: ActivityFollow | ActivityLike | ActivityCreate,
+  object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce,
   t: Transaction,
   audience?: ActivityAudience
 ): Promise<ActivityUndo> {
index f256f8d21fca772d4c04bfab439e07bfaacde459..69841486765b16df1404bab64360713282de1e0d 100644 (file)
@@ -3,16 +3,37 @@ import { VideoPrivacy } from '../../../shared/models/videos'
 import { getServerActor } from '../../helpers/utils'
 import { VideoModel } from '../../models/video/video'
 import { VideoShareModel } from '../../models/video/video-share'
-import { sendVideoAnnounce } from './send'
+import { sendUndoAnnounce, sendVideoAnnounce } from './send'
 import { getAnnounceActivityPubUrl } from './url'
+import { VideoChannelModel } from '../../models/video/video-channel'
 
 async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) {
   if (video.privacy === VideoPrivacy.PRIVATE) return undefined
 
+  return Promise.all([
+    shareByServer(video, t),
+    shareByVideoChannel(video, t)
+  ])
+}
+
+async function changeVideoChannelShare (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) {
+  await undoShareByVideoChannel(video, oldVideoChannel, t)
+
+  await shareByVideoChannel(video, t)
+}
+
+export {
+  changeVideoChannelShare,
+  shareVideoByServerAndChannel
+}
+
+// ---------------------------------------------------------------------------
+
+async function shareByServer (video: VideoModel, t: Transaction) {
   const serverActor = await getServerActor()
 
   const serverShareUrl = getAnnounceActivityPubUrl(video.url, serverActor)
-  const serverSharePromise = VideoShareModel.findOrCreate({
+  return VideoShareModel.findOrCreate({
     defaults: {
       actorId: serverActor.id,
       videoId: video.id,
@@ -27,9 +48,11 @@ async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction)
 
     return undefined
   })
+}
 
+async function shareByVideoChannel (video: VideoModel, t: Transaction) {
   const videoChannelShareUrl = getAnnounceActivityPubUrl(video.url, video.VideoChannel.Actor)
-  const videoChannelSharePromise = VideoShareModel.findOrCreate({
+  return VideoShareModel.findOrCreate({
     defaults: {
       actorId: video.VideoChannel.actorId,
       videoId: video.id,
@@ -40,17 +63,17 @@ async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction)
     },
     transaction: t
   }).then(([ videoChannelShare, created ]) => {
-    if (created) return sendVideoAnnounce(serverActor, videoChannelShare, video, t)
+    if (created) return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
 
     return undefined
   })
-
-  return Promise.all([
-    serverSharePromise,
-    videoChannelSharePromise
-  ])
 }
 
-export {
-  shareVideoByServerAndChannel
+async function undoShareByVideoChannel (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) {
+  // Load old share
+  const oldShare = await VideoShareModel.load(oldVideoChannel.actorId, video.id, t)
+  if (!oldShare) return new Error('Cannot find old video channel share ' + oldVideoChannel.actorId + ' for video ' + video.id)
+
+  await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t)
+  await oldShare.destroy({ transaction: t })
 }
index b81acbb35b102baf27f976a6c0f44a245f49be39..2899acff3a7448fa96b0aa2433f3809aee0cc865 100644 (file)
@@ -137,6 +137,13 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje
   return attributes
 }
 
+function getOrCreateVideoChannel (videoObject: VideoTorrentObject) {
+  const channel = videoObject.attributedTo.find(a => a.type === 'Group')
+  if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
+
+  return getOrCreateActorAndServerAndModel(channel.id)
+}
+
 async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor: ActorModel) {
   logger.debug('Adding remote video %s.', videoObject.id)
 
@@ -199,10 +206,7 @@ async function getOrCreateAccountAndVideoAndChannel (videoObject: VideoTorrentOb
     actor = await getOrCreateActorAndServerAndModel(actorObj.id)
   }
 
-  const channel = videoObject.attributedTo.find(a => a.type === 'Group')
-  if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
-
-  const channelActor = await getOrCreateActorAndServerAndModel(channel.id)
+  const channelActor = await getOrCreateVideoChannel(videoObject)
 
   const options = {
     arguments: [ videoObject, channelActor ],
@@ -301,6 +305,7 @@ export {
   videoActivityObjectToDBAttributes,
   videoFileActivityUrlToDBAttributes,
   getOrCreateVideo,
+  getOrCreateVideoChannel,
   addVideoShares}
 
 // ---------------------------------------------------------------------------
index b93dccc50c6866c2bf1a45092993e3944f6bad05..aa2afb068e24be1ee9ef55975d9055df3379f0fd 100644 (file)
@@ -6,6 +6,7 @@ import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, t
 import {
   isVideoAbuseReasonValid,
   isVideoCategoryValid,
+  isVideoChannelOfAccountExist,
   isVideoDescriptionValid,
   isVideoExist,
   isVideoFile,
@@ -23,7 +24,6 @@ import { logger } from '../../helpers/logger'
 import { CONSTRAINTS_FIELDS } from '../../initializers'
 import { UserModel } from '../../models/account/user'
 import { VideoModel } from '../../models/video/video'
-import { VideoChannelModel } from '../../models/video/video-channel'
 import { VideoShareModel } from '../../models/video/video-share'
 import { authenticate } from '../oauth'
 import { areValidationErrors } from './utils'
@@ -75,7 +75,10 @@ const videosAddValidator = [
     .optional()
     .toInt()
     .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
-  body('channelId').custom(isIdValid).withMessage('Should have correct video channel id'),
+  body('channelId')
+    .toInt()
+    .custom(isIdValid)
+    .withMessage('Should have correct video channel id'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
@@ -86,16 +89,7 @@ const videosAddValidator = [
     const videoFile: Express.Multer.File = req.files['videofile'][0]
     const user = res.locals.oauth.token.User
 
-    const videoChannel = await VideoChannelModel.loadByIdAndAccount(req.body.channelId, user.Account.id)
-    if (!videoChannel) {
-      res.status(400)
-        .json({ error: 'Unknown video video channel for this account.' })
-        .end()
-
-      return
-    }
-
-    res.locals.videoChannel = videoChannel
+    if (!await isVideoChannelOfAccountExist(req.body.channelId, user.Account.id, res)) return
 
     const isAble = await user.isAbleToUploadVideo(videoFile)
     if (isAble === false) {
@@ -173,6 +167,10 @@ const videosUpdateValidator = [
     .optional()
     .toBoolean()
     .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
+  body('channelId')
+    .optional()
+    .toInt()
+    .custom(isIdValid).withMessage('Should have correct video channel id'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking videosUpdate parameters', { parameters: req.body })
@@ -184,7 +182,8 @@ const videosUpdateValidator = [
     const video = res.locals.video
 
     // Check if the user who did the request is able to update the video
-    if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
+    const user = res.locals.oauth.token.User
+    if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
 
     if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) {
       return res.status(409)
@@ -192,6 +191,8 @@ const videosUpdateValidator = [
         .end()
     }
 
+    if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user.Account.id, res)) return
+
     return next()
   }
 ]
index 6f770957faab736814a94baba94ed0e92a8d19fe..602cc69b90f1d09b9480e7e1c935268aada22a2b 100644 (file)
@@ -98,6 +98,15 @@ export class VideoShareModel extends Model<VideoShareModel> {
     })
   }
 
+  static loadByUrl (url: string, t: Sequelize.Transaction) {
+    return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
+      where: {
+        url
+      },
+      transaction: t
+    })
+  }
+
   static loadActorsByShare (videoId: number, t: Sequelize.Transaction) {
     const query = {
       where: {
index ea466fccd3b3fe78702d639879069cc649437f69..fe8c30655a31b1333f0529d0c6329b86ddbbe13f 100644 (file)
@@ -130,11 +130,27 @@ enum ScopeNames {
     }
 
     const videoChannelInclude = {
-      attributes: [ 'name', 'description' ],
+      attributes: [ 'name', 'description', 'id' ],
       model: VideoChannelModel.unscoped(),
       required: true,
       where: {},
       include: [
+        {
+          attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
+        },
         accountInclude
       ]
     }
@@ -771,12 +787,17 @@ export class VideoModel extends Model<VideoModel> {
             }
           },
           {
-            preferredUsername: Sequelize.where(Sequelize.col('preferredUsername'), {
+            preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), {
               [ Sequelize.Op.iLike ]: '%' + value + '%'
             })
           },
           {
-            host: Sequelize.where(Sequelize.col('host'), {
+            preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), {
+              [ Sequelize.Op.iLike ]: '%' + value + '%'
+            })
+          },
+          {
+            host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), {
               [ Sequelize.Op.iLike ]: '%' + value + '%'
             })
           }
@@ -1043,6 +1064,7 @@ export class VideoModel extends Model<VideoModel> {
 
   toFormattedJSON (): Video {
     const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
+    const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
 
     return {
       id: this.id,
@@ -1085,6 +1107,15 @@ export class VideoModel extends Model<VideoModel> {
         url: formattedAccount.url,
         host: formattedAccount.host,
         avatar: formattedAccount.avatar
+      },
+      channel: {
+        id: formattedVideoChannel.id,
+        uuid: formattedVideoChannel.uuid,
+        name: formattedVideoChannel.name,
+        displayName: formattedVideoChannel.displayName,
+        url: formattedVideoChannel.url,
+        host: formattedVideoChannel.host,
+        avatar: formattedVideoChannel.avatar
       }
     }
   }
index 585b6a2b59a37b9c40307b0a14e01015c088d000..35c418f7c65abf342fc9d60f9d8fd4ac23b05fd5 100644 (file)
@@ -2,8 +2,8 @@
 
 import * as chai from 'chai'
 import 'mocha'
-import { User } from '../../../../shared/index'
-import { doubleFollow, flushAndRunMultipleServers, getVideoChannelVideos, uploadVideo, wait } from '../../utils'
+import { User, Video } from '../../../../shared/index'
+import { doubleFollow, flushAndRunMultipleServers, getVideoChannelVideos, updateVideo, uploadVideo, wait } from '../../utils'
 import {
   addVideoChannel,
   deleteVideoChannel,
@@ -25,8 +25,11 @@ describe('Test video channels', function () {
   let servers: ServerInfo[]
   let userInfo: User
   let accountUUID: string
-  let videoChannelId: number
-  let videoChannelUUID: string
+  let firstVideoChannelId: number
+  let firstVideoChannelUUID: string
+  let secondVideoChannelId: number
+  let secondVideoChannelUUID: string
+  let videoUUID: string
 
   before(async function () {
     this.timeout(30000)
@@ -42,6 +45,9 @@ describe('Test video channels', function () {
       const res = await getMyUserInformation(servers[0].url, servers[0].accessToken)
       const user: User = res.body
       accountUUID = user.account.uuid
+
+      firstVideoChannelId = user.videoChannels[0].id
+      firstVideoChannelUUID = user.videoChannels[0].uuid
     }
 
     await wait(5000)
@@ -58,17 +64,22 @@ describe('Test video channels', function () {
   it('Should create another video channel', async function () {
     this.timeout(10000)
 
-    const videoChannel = {
-      displayName: 'second video channel',
-      description: 'super video channel description',
-      support: 'super video channel support text'
+    {
+      const videoChannel = {
+        displayName: 'second video channel',
+        description: 'super video channel description',
+        support: 'super video channel support text'
+      }
+      const res = await addVideoChannel(servers[ 0 ].url, servers[ 0 ].accessToken, videoChannel)
+      secondVideoChannelId = res.body.videoChannel.id
+      secondVideoChannelUUID = res.body.videoChannel.uuid
     }
-    const res = await addVideoChannel(servers[0].url, servers[0].accessToken, videoChannel)
-    videoChannelId = res.body.videoChannel.id
-    videoChannelUUID = res.body.videoChannel.uuid
 
     // The channel is 1 is propagated to servers 2
-    await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'my video name', channelId: videoChannelId })
+    {
+      const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name', channelId: secondVideoChannelId })
+      videoUUID = res.body.video.uuid
+    }
 
     await wait(3000)
   })
@@ -130,7 +141,7 @@ describe('Test video channels', function () {
       support: 'video channel support text updated'
     }
 
-    await updateVideoChannel(servers[0].url, servers[0].accessToken, videoChannelId, videoChannelAttributes)
+    await updateVideoChannel(servers[0].url, servers[0].accessToken, secondVideoChannelId, videoChannelAttributes)
 
     await wait(3000)
   })
@@ -149,7 +160,7 @@ describe('Test video channels', function () {
   })
 
   it('Should get video channel', async function () {
-    const res = await getVideoChannel(servers[0].url, videoChannelId)
+    const res = await getVideoChannel(servers[0].url, secondVideoChannelId)
 
     const videoChannel = res.body
     expect(videoChannel.displayName).to.equal('video channel updated')
@@ -157,20 +168,45 @@ describe('Test video channels', function () {
     expect(videoChannel.support).to.equal('video channel support text updated')
   })
 
-  it('Should list the video channel videos', async function () {
+  it('Should list the second video channel videos', async function () {
     this.timeout(10000)
 
     for (const server of servers) {
-      const res = await getVideoChannelVideos(server.url, server.accessToken, videoChannelUUID, 0, 5)
-      expect(res.body.total).to.equal(1)
-      expect(res.body.data).to.be.an('array')
-      expect(res.body.data).to.have.lengthOf(1)
-      expect(res.body.data[0].name).to.equal('my video name')
+      const res1 = await getVideoChannelVideos(server.url, server.accessToken, secondVideoChannelUUID, 0, 5)
+      expect(res1.body.total).to.equal(1)
+      expect(res1.body.data).to.be.an('array')
+      expect(res1.body.data).to.have.lengthOf(1)
+      expect(res1.body.data[0].name).to.equal('my video name')
+    }
+  })
+
+  it('Should change the video channel of a video', async function () {
+    this.timeout(10000)
+
+    await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { channelId: firstVideoChannelId })
+
+    await wait(5000)
+  })
+
+  it('Should list the first video channel videos', async function () {
+    this.timeout(10000)
+
+    for (const server of servers) {
+      const res1 = await getVideoChannelVideos(server.url, server.accessToken, secondVideoChannelUUID, 0, 5)
+      expect(res1.body.total).to.equal(0)
+
+      const res2 = await getVideoChannelVideos(server.url, server.accessToken, firstVideoChannelUUID, 0, 5)
+      expect(res2.body.total).to.equal(1)
+
+      const videos: Video[] = res2.body.data
+      expect(videos).to.be.an('array')
+      expect(videos).to.have.lengthOf(1)
+      expect(videos[0].name).to.equal('my video name')
     }
   })
 
   it('Should delete video channel', async function () {
-    await deleteVideoChannel(servers[0].url, servers[0].accessToken, videoChannelId)
+    await deleteVideoChannel(servers[0].url, servers[0].accessToken, secondVideoChannelId)
   })
 
   it('Should have video channel deleted', async function () {
index 870dfd21fb49975c95a9ebfccd69357bc9424b90..07c4ffc7769cb8c779baf59158375858e9e9dd35 100644 (file)
@@ -15,7 +15,7 @@ import {
   ServerInfo,
   testImage
 } from '../'
-import { VideoPrivacy } from '../../../../shared/models/videos'
+import { VideoDetails, VideoPrivacy } from '../../../../shared/models/videos'
 import { readdirPromise } from '../../../helpers/core-utils'
 import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers'
 import { dateIsValid, webtorrentAdd } from '../index'
@@ -385,6 +385,7 @@ function updateVideo (url: string, accessToken: string, id: number | string, att
   if (attributes.description) body['description'] = attributes.description
   if (attributes.tags) body['tags'] = attributes.tags
   if (attributes.privacy) body['privacy'] = attributes.privacy
+  if (attributes.channelId) body['channelId'] = attributes.channelId
 
   // Upload request
   if (attributes.thumbnailfile || attributes.previewfile) {
@@ -489,6 +490,8 @@ async function completeVideoCheck (
   expect(video.account.uuid).to.be.a('string')
   expect(video.account.host).to.equal(attributes.account.host)
   expect(video.account.name).to.equal(attributes.account.name)
+  expect(video.channel.displayName).to.equal(attributes.channel.name)
+  expect(video.channel.name).to.have.lengthOf(36)
   expect(video.likes).to.equal(attributes.likes)
   expect(video.dislikes).to.equal(attributes.dislikes)
   expect(video.isLocal).to.equal(attributes.isLocal)
@@ -498,19 +501,19 @@ async function completeVideoCheck (
   expect(dateIsValid(video.updatedAt)).to.be.true
 
   const res = await getVideo(url, video.uuid)
-  const videoDetails = res.body
+  const videoDetails: VideoDetails = res.body
 
   expect(videoDetails.files).to.have.lengthOf(attributes.files.length)
   expect(videoDetails.tags).to.deep.equal(attributes.tags)
   expect(videoDetails.account.name).to.equal(attributes.account.name)
   expect(videoDetails.account.host).to.equal(attributes.account.host)
-  expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled)
-
   expect(videoDetails.channel.displayName).to.equal(attributes.channel.name)
   expect(videoDetails.channel.name).to.have.lengthOf(36)
+  expect(videoDetails.channel.host).to.equal(attributes.account.host)
   expect(videoDetails.channel.isLocal).to.equal(attributes.channel.isLocal)
-  expect(dateIsValid(videoDetails.channel.createdAt)).to.be.true
-  expect(dateIsValid(videoDetails.channel.updatedAt)).to.be.true
+  expect(dateIsValid(videoDetails.channel.createdAt.toString())).to.be.true
+  expect(dateIsValid(videoDetails.channel.updatedAt.toString())).to.be.true
+  expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled)
 
   for (const attributeFile of attributes.files) {
     const file = videoDetails.files.find(f => f.resolution.id === attributeFile.resolution)
index f555f011842a526decec30b2e61596033f979993..46e883e5f081d7a5a5a7dae888ab1b01e3919113 100644 (file)
@@ -64,7 +64,7 @@ export interface ActivityAnnounce extends BaseActivity {
 
 export interface ActivityUndo extends BaseActivity {
   type: 'Undo',
-  object: ActivityFollow | ActivityLike | ActivityCreate
+  object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce
 }
 
 export interface ActivityLike extends BaseActivity {
index f91616519da84c911a7699f01da827404a01914b..6b3b1b47c936730ae2b5a9e40ee56c4e4cde4451 100644 (file)
@@ -8,7 +8,7 @@ export interface Actor {
   host: string
   followingCount: number
   followersCount: number
-  createdAt: Date
-  updatedAt: Date
+  createdAt: Date | string
+  updatedAt: Date | string
   avatar: Avatar
 }
index 3a205bb83058c886b105c24d51681bbac94eed42..c368d8464ce57f4ce69b9cc2c8a3d045ebc813c5 100644 (file)
@@ -11,6 +11,7 @@ export interface VideoUpdate {
   tags?: string[]
   commentsEnabled?: boolean
   nsfw?: boolean
+  channelId?: number
   thumbnailfile?: Blob
   previewfile?: Blob
 }
index ba1881da34f47656cee15b7526dae004f0dd5533..eb40e82deb3b97f95434202aeb0d68354c41c4c6 100644 (file)
@@ -48,6 +48,16 @@ export interface Video {
     host: string
     avatar: Avatar
   }
+
+  channel: {
+    id: number
+    uuid: string
+    name: string
+    displayName: string
+    url: string
+    host: string
+    avatar: Avatar
+  }
 }
 
 export interface VideoDetails extends Video {