From: Chocobozzz Date: Wed, 2 Jan 2019 15:37:43 +0000 (+0100) Subject: Add import finished and video published notifs X-Git-Tag: v1.2.0-rc.1~44 X-Git-Url: https://git.immae.eu/?a=commitdiff_plain;h=dc13348070d808d0ba3feb56a435b835c2e7e791;hp=6e7e63b83f08ba68edc2bb9f72ff03d1802e45df;p=github%2FChocobozzz%2FPeerTube.git Add import finished and video published notifs --- diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts index cef1d237c..4b81777a4 100644 --- a/server/controllers/api/users/my-notifications.ts +++ b/server/controllers/api/users/my-notifications.ts @@ -14,10 +14,11 @@ import { getFormattedObjects } from '../../../helpers/utils' import { UserNotificationModel } from '../../../models/account/user-notification' import { meRouter } from './me' import { + listUserNotificationsValidator, markAsReadUserNotificationsValidator, updateNotificationSettingsValidator } from '../../../middlewares/validators/user-notifications' -import { UserNotificationSetting } from '../../../../shared/models/users' +import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users' import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting' const myNotificationsRouter = express.Router() @@ -34,6 +35,7 @@ myNotificationsRouter.get('/me/notifications', userNotificationsSortValidator, setDefaultSort, setDefaultPagination, + listUserNotificationsValidator, asyncMiddleware(listUserNotifications) ) @@ -61,7 +63,11 @@ async function updateNotificationSettings (req: express.Request, res: express.Re await UserNotificationSettingModel.update({ newVideoFromSubscription: body.newVideoFromSubscription, - newCommentOnMyVideo: body.newCommentOnMyVideo + newCommentOnMyVideo: body.newCommentOnMyVideo, + videoAbuseAsModerator: body.videoAbuseAsModerator, + blacklistOnMyVideo: body.blacklistOnMyVideo, + myVideoPublished: body.myVideoPublished, + myVideoImportFinished: body.myVideoImportFinished }, query) return res.status(204).end() @@ -70,7 +76,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re async function listUserNotifications (req: express.Request, res: express.Response) { const user: UserModel = res.locals.oauth.token.User - const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort) + const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort, req.query.unread) return res.json(getFormattedObjects(resultList.data, resultList.total)) } diff --git a/server/initializers/migrations/0315-user-notifications.ts b/server/initializers/migrations/0315-user-notifications.ts index 2bd9c657d..8c54c5d6c 100644 --- a/server/initializers/migrations/0315-user-notifications.ts +++ b/server/initializers/migrations/0315-user-notifications.ts @@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS "userNotificationSetting" ("id" SERIAL, "newCommentOnMyVideo" INTEGER NOT NULL DEFAULT NULL, "videoAbuseAsModerator" INTEGER NOT NULL DEFAULT NULL, "blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL, +"myVideoPublished" INTEGER NOT NULL DEFAULT NULL, +"myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL, "userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, @@ -24,8 +26,8 @@ PRIMARY KEY ("id")) { const query = 'INSERT INTO "userNotificationSetting" ' + '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + - '"userId", "createdAt", "updatedAt") ' + - '(SELECT 2, 2, 4, 4, id, NOW(), NOW() FROM "user")' + '"myVideoPublished", "myVideoImportFinished", "userId", "createdAt", "updatedAt") ' + + '(SELECT 2, 2, 4, 4, 2, 2, id, NOW(), NOW() FROM "user")' await utils.sequelize.query(query) } diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index d766e655b..6dc8f2adf 100644 --- a/server/lib/emailer.ts +++ b/server/lib/emailer.ts @@ -10,6 +10,7 @@ import { readFileSync } from 'fs-extra' import { VideoCommentModel } from '../models/video/video-comment' import { VideoAbuseModel } from '../models/video/video-abuse' import { VideoBlacklistModel } from '../models/video/video-blacklist' +import { VideoImportModel } from '../models/video/video-import' class Emailer { @@ -102,6 +103,66 @@ class Emailer { return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } + myVideoPublishedNotification (to: string[], video: VideoModel) { + const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() + + const text = `Hi dear user,\n\n` + + `Your video ${video.name} has been published.` + + `\n\n` + + `You can view it on ${videoUrl} ` + + `\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to, + subject: `Your video ${video.name} is published`, + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + myVideoImportSuccessNotification (to: string[], videoImport: VideoImportModel) { + const videoUrl = CONFIG.WEBSERVER.URL + videoImport.Video.getWatchStaticPath() + + const text = `Hi dear user,\n\n` + + `Your video import ${videoImport.getTargetIdentifier()} is finished.` + + `\n\n` + + `You can view the imported video on ${videoUrl} ` + + `\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to, + subject: `Your video import ${videoImport.getTargetIdentifier()} is finished`, + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + myVideoImportErrorNotification (to: string[], videoImport: VideoImportModel) { + const importUrl = CONFIG.WEBSERVER.URL + '/my-account/video-imports' + + const text = `Hi dear user,\n\n` + + `Your video import ${videoImport.getTargetIdentifier()} encountered an error.` + + `\n\n` + + `See your videos import dashboard for more information: ${importUrl}` + + `\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to, + subject: `Your video import ${videoImport.getTargetIdentifier()} encountered an error`, + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + addNewCommentOnMyVideoNotification (to: string[], comment: VideoCommentModel) { const accountName = comment.Account.getDisplayName() const video = comment.Video diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts index 480d324dc..593e43cc5 100644 --- a/server/lib/job-queue/handlers/video-file.ts +++ b/server/lib/job-queue/handlers/video-file.ts @@ -68,17 +68,17 @@ async function processVideoFile (job: Bull.Job) { async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) { if (video === undefined) return undefined - const { videoDatabase, isNewVideo } = await sequelizeTypescript.transaction(async t => { + const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { // Maybe the video changed in database, refresh it let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) // Video does not exist anymore if (!videoDatabase) return undefined - let isNewVideo = false + let videoPublished = false // We transcoded the video file in another format, now we can publish it if (videoDatabase.state !== VideoState.PUBLISHED) { - isNewVideo = true + videoPublished = true videoDatabase.state = VideoState.PUBLISHED videoDatabase.publishedAt = new Date() @@ -86,12 +86,15 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) { } // If the video was not published, we consider it is a new one for other instances - await federateVideoIfNeeded(videoDatabase, isNewVideo, t) + await federateVideoIfNeeded(videoDatabase, videoPublished, t) - return { videoDatabase, isNewVideo } + return { videoDatabase, videoPublished } }) - if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase) + if (videoPublished) { + Notifier.Instance.notifyOnNewVideo(videoDatabase) + Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase) + } } async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: boolean) { @@ -100,7 +103,7 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo // Outside the transaction (IO on disk) const { videoFileResolution } = await videoArg.getOriginalFileResolution() - const videoDatabase = await sequelizeTypescript.transaction(async t => { + const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { // Maybe the video changed in database, refresh it let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid, t) // Video does not exist anymore @@ -113,6 +116,8 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo { resolutions: resolutionsEnabled } ) + let videoPublished = false + if (resolutionsEnabled.length !== 0) { const tasks: Bluebird>[] = [] @@ -130,6 +135,8 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo logger.info('Transcoding jobs created for uuid %s.', videoDatabase.uuid, { resolutionsEnabled }) } else { + videoPublished = true + // No transcoding to do, it's now published videoDatabase.state = VideoState.PUBLISHED videoDatabase = await videoDatabase.save({ transaction: t }) @@ -139,10 +146,11 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo await federateVideoIfNeeded(videoDatabase, isNewVideo, t) - return videoDatabase + return { videoDatabase, videoPublished } }) if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase) + if (videoPublished) Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase) } // --------------------------------------------------------------------------- diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 29cd1198c..12004dcd7 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -197,6 +197,7 @@ async function processFile (downloader: () => Promise, videoImport: Vide }) Notifier.Instance.notifyOnNewVideo(videoImportUpdated.Video) + Notifier.Instance.notifyOnFinishedVideoImport(videoImportUpdated, true) // Create transcoding jobs? if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) { @@ -220,6 +221,8 @@ async function processFile (downloader: () => Promise, videoImport: Vide videoImport.state = VideoImportState.FAILED await videoImport.save() + Notifier.Instance.notifyOnFinishedVideoImport(videoImport, false) + throw err } } diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts index a21b50b2d..11b0937e9 100644 --- a/server/lib/notifier.ts +++ b/server/lib/notifier.ts @@ -11,6 +11,8 @@ import { VideoPrivacy, VideoState } from '../../shared/models/videos' import { VideoAbuseModel } from '../models/video/video-abuse' import { VideoBlacklistModel } from '../models/video/video-blacklist' import * as Bluebird from 'bluebird' +import { VideoImportModel } from '../models/video/video-import' +import { AccountBlocklistModel } from '../models/account/account-blocklist' class Notifier { @@ -26,6 +28,14 @@ class Notifier { .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err })) } + notifyOnPendingVideoPublished (video: VideoModel): void { + // Only notify on public videos that has been published while the user waited transcoding/scheduled update + if (video.waitTranscoding === false && !video.ScheduleVideoUpdate) return + + this.notifyOwnedVideoHasBeenPublished(video) + .catch(err => logger.error('Cannot notify owner that its video %s has been published.', video.url, { err })) + } + notifyOnNewComment (comment: VideoCommentModel): void { this.notifyVideoOwnerOfNewComment(comment) .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err })) @@ -46,6 +56,11 @@ class Notifier { .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', video.url, { err })) } + notifyOnFinishedVideoImport (videoImport: VideoImportModel, success: boolean): void { + this.notifyOwnerVideoImportIsFinished(videoImport, success) + .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err })) + } + private async notifySubscribersOfNewVideo (video: VideoModel) { // List all followers that are users const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId) @@ -80,6 +95,9 @@ class Notifier { // Not our user or user comments its own video if (!user || comment.Account.userId === user.id) return + const accountMuted = await AccountBlocklistModel.isAccountMutedBy(user.Account.id, comment.accountId) + if (accountMuted) return + logger.info('Notifying user %s of new comment %s.', user.username, comment.url) function settingGetter (user: UserModel) { @@ -188,6 +206,64 @@ class Notifier { return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) } + private async notifyOwnedVideoHasBeenPublished (video: VideoModel) { + const user = await UserModel.loadByVideoId(video.id) + if (!user) return + + logger.info('Notifying user %s of the publication of its video %s.', user.username, video.url) + + function settingGetter (user: UserModel) { + return user.NotificationSetting.myVideoPublished + } + + async function notificationCreator (user: UserModel) { + const notification = await UserNotificationModel.create({ + type: UserNotificationType.MY_VIDEO_PUBLISHED, + userId: user.id, + videoId: video.id + }) + notification.Video = video + + return notification + } + + function emailSender (emails: string[]) { + return Emailer.Instance.myVideoPublishedNotification(emails, video) + } + + return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) + } + + private async notifyOwnerVideoImportIsFinished (videoImport: VideoImportModel, success: boolean) { + const user = await UserModel.loadByVideoImportId(videoImport.id) + if (!user) return + + logger.info('Notifying user %s its video import %s is finished.', user.username, videoImport.getTargetIdentifier()) + + function settingGetter (user: UserModel) { + return user.NotificationSetting.myVideoImportFinished + } + + async function notificationCreator (user: UserModel) { + const notification = await UserNotificationModel.create({ + type: success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR, + userId: user.id, + videoImportId: videoImport.id + }) + notification.VideoImport = videoImport + + return notification + } + + function emailSender (emails: string[]) { + return success + ? Emailer.Instance.myVideoImportSuccessNotification(emails, videoImport) + : Emailer.Instance.myVideoImportErrorNotification(emails, videoImport) + } + + return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) + } + private async notify (options: { users: UserModel[], notificationCreator: (user: UserModel) => Promise, diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts index b7fb029f1..2618a5857 100644 --- a/server/lib/schedulers/update-videos-scheduler.ts +++ b/server/lib/schedulers/update-videos-scheduler.ts @@ -6,6 +6,7 @@ import { federateVideoIfNeeded } from '../activitypub' import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers' import { VideoPrivacy } from '../../../shared/models/videos' import { Notifier } from '../notifier' +import { VideoModel } from '../../models/video/video' export class UpdateVideosScheduler extends AbstractScheduler { @@ -24,8 +25,9 @@ export class UpdateVideosScheduler extends AbstractScheduler { private async updateVideos () { if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined - return sequelizeTypescript.transaction(async t => { + const publishedVideos = await sequelizeTypescript.transaction(async t => { const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t) + const publishedVideos: VideoModel[] = [] for (const schedule of schedules) { const video = schedule.Video @@ -42,13 +44,21 @@ export class UpdateVideosScheduler extends AbstractScheduler { await federateVideoIfNeeded(video, isNewVideo, t) if (oldPrivacy === VideoPrivacy.UNLISTED || oldPrivacy === VideoPrivacy.PRIVATE) { - Notifier.Instance.notifyOnNewVideo(video) + video.ScheduleVideoUpdate = schedule + publishedVideos.push(video) } } await schedule.destroy({ transaction: t }) } + + return publishedVideos }) + + for (const v of publishedVideos) { + Notifier.Instance.notifyOnNewVideo(v) + Notifier.Instance.notifyOnPendingVideoPublished(v) + } } static get Instance () { diff --git a/server/lib/user.ts b/server/lib/user.ts index 72127819c..481571828 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -100,6 +100,8 @@ function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Tr userId: user.id, newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, + myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, + myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL }, { transaction: t }) diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts index 8202f307e..1c31f0a73 100644 --- a/server/middlewares/validators/user-notifications.ts +++ b/server/middlewares/validators/user-notifications.ts @@ -1,11 +1,26 @@ import * as express from 'express' import 'express-validator' -import { body } from 'express-validator/check' +import { body, query } from 'express-validator/check' import { logger } from '../../helpers/logger' import { areValidationErrors } from './utils' import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' import { isIntArray } from '../../helpers/custom-validators/misc' +const listUserNotificationsValidator = [ + query('unread') + .optional() + .toBoolean() + .isBoolean().withMessage('Should have a valid unread boolean'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking listUserNotificationsValidator parameters', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + const updateNotificationSettingsValidator = [ body('newVideoFromSubscription') .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'), @@ -41,6 +56,7 @@ const markAsReadUserNotificationsValidator = [ // --------------------------------------------------------------------------- export { + listUserNotificationsValidator, updateNotificationSettingsValidator, markAsReadUserNotificationsValidator } diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts index fa2819235..54ac290c4 100644 --- a/server/models/account/account-blocklist.ts +++ b/server/models/account/account-blocklist.ts @@ -72,6 +72,21 @@ export class AccountBlocklistModel extends Model { }) BlockedAccount: AccountModel + static isAccountMutedBy (accountId: number, targetAccountId: number) { + const query = { + attributes: [ 'id' ], + where: { + accountId, + targetAccountId + }, + raw: true + } + + return AccountBlocklistModel.unscoped() + .findOne(query) + .then(a => !!a) + } + static loadByAccountAndTarget (accountId: number, targetAccountId: number) { const query = { where: { diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts index bc24b1e33..6470defa7 100644 --- a/server/models/account/user-notification-setting.ts +++ b/server/models/account/user-notification-setting.ts @@ -65,6 +65,24 @@ export class UserNotificationSettingModel extends Model throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoPublished') + ) + @Column + myVideoPublished: UserNotificationSettingValue + + @AllowNull(false) + @Default(null) + @Is( + 'UserNotificationSettingMyVideoImportFinished', + value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoImportFinished') + ) + @Column + myVideoImportFinished: UserNotificationSettingValue + @ForeignKey(() => UserModel) @Column userId: number @@ -94,7 +112,9 @@ export class UserNotificationSettingModel extends Model VideoModel.unscoped(), + required + } +} + +function buildChannelInclude () { + return { + required: true, + attributes: [ 'id', 'name' ], + model: () => VideoChannelModel.unscoped() + } +} + +function buildAccountInclude () { + return { + required: true, + attributes: [ 'id', 'name' ], + model: () => AccountModel.unscoped() + } +} + @Scopes({ [ScopeNames.WITH_ALL]: { include: [ + Object.assign(buildVideoInclude(false), { + include: [ buildChannelInclude() ] + }), { - attributes: [ 'id', 'uuid', 'name' ], - model: () => VideoModel.unscoped(), - required: false, - include: [ - { - required: true, - attributes: [ 'id', 'name' ], - model: () => VideoChannelModel.unscoped() - } - ] - }, - { - attributes: [ 'id' ], + attributes: [ 'id', 'originCommentId' ], model: () => VideoCommentModel.unscoped(), required: false, include: [ - { - required: true, - attributes: [ 'id', 'name' ], - model: () => AccountModel.unscoped() - }, - { - required: true, - attributes: [ 'id', 'uuid', 'name' ], - model: () => VideoModel.unscoped() - } + buildAccountInclude(), + buildVideoInclude(true) ] }, { attributes: [ 'id' ], model: () => VideoAbuseModel.unscoped(), required: false, - include: [ - { - required: true, - attributes: [ 'id', 'uuid', 'name' ], - model: () => VideoModel.unscoped() - } - ] + include: [ buildVideoInclude(true) ] }, { attributes: [ 'id' ], model: () => VideoBlacklistModel.unscoped(), required: false, - include: [ - { - required: true, - attributes: [ 'id', 'uuid', 'name' ], - model: () => VideoModel.unscoped() - } - ] + include: [ buildVideoInclude(true) ] + }, + { + attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ], + model: () => VideoImportModel.unscoped(), + required: false, + include: [ buildVideoInclude(false) ] } ] } @@ -166,8 +181,20 @@ export class UserNotificationModel extends Model { }) VideoBlacklist: VideoBlacklistModel - static listForApi (userId: number, start: number, count: number, sort: string) { - const query = { + @ForeignKey(() => VideoImportModel) + @Column + videoImportId: number + + @BelongsTo(() => VideoImportModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + VideoImport: VideoImportModel + + static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { + const query: IFindOptions = { offset: start, limit: count, order: getSort(sort), @@ -176,6 +203,8 @@ export class UserNotificationModel extends Model { } } + if (unread !== undefined) query.where['read'] = !unread + return UserNotificationModel.scope(ScopeNames.WITH_ALL) .findAndCountAll(query) .then(({ rows, count }) => { @@ -200,45 +229,39 @@ export class UserNotificationModel extends Model { } toFormattedJSON (): UserNotification { - const video = this.Video ? { - id: this.Video.id, - uuid: this.Video.uuid, - name: this.Video.name, + const video = this.Video ? Object.assign(this.formatVideo(this.Video), { channel: { id: this.Video.VideoChannel.id, displayName: this.Video.VideoChannel.getDisplayName() } + }) : undefined + + const videoImport = this.VideoImport ? { + id: this.VideoImport.id, + video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined, + torrentName: this.VideoImport.torrentName, + magnetUri: this.VideoImport.magnetUri, + targetUrl: this.VideoImport.targetUrl } : undefined const comment = this.Comment ? { id: this.Comment.id, + threadId: this.Comment.getThreadId(), account: { id: this.Comment.Account.id, displayName: this.Comment.Account.getDisplayName() }, - video: { - id: this.Comment.Video.id, - uuid: this.Comment.Video.uuid, - name: this.Comment.Video.name - } + video: this.formatVideo(this.Comment.Video) } : undefined const videoAbuse = this.VideoAbuse ? { id: this.VideoAbuse.id, - video: { - id: this.VideoAbuse.Video.id, - uuid: this.VideoAbuse.Video.uuid, - name: this.VideoAbuse.Video.name - } + video: this.formatVideo(this.VideoAbuse.Video) } : undefined const videoBlacklist = this.VideoBlacklist ? { id: this.VideoBlacklist.id, - video: { - id: this.VideoBlacklist.Video.id, - uuid: this.VideoBlacklist.Video.uuid, - name: this.VideoBlacklist.Video.name - } + video: this.formatVideo(this.VideoBlacklist.Video) } : undefined return { @@ -246,6 +269,7 @@ export class UserNotificationModel extends Model { type: this.type, read: this.read, video, + videoImport, comment, videoAbuse, videoBlacklist, @@ -253,4 +277,12 @@ export class UserNotificationModel extends Model { updatedAt: this.updatedAt.toISOString() } } + + private formatVideo (video: VideoModel) { + return { + id: video.id, + uuid: video.uuid, + name: video.name + } + } } diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 55ec14d05..33f56f641 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -48,6 +48,7 @@ import { UserNotificationSettingModel } from './user-notification-setting' import { VideoModel } from '../video/video' import { ActorModel } from '../activitypub/actor' import { ActorFollowModel } from '../activitypub/actor-follow' +import { VideoImportModel } from '../video/video-import' enum ScopeNames { WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' @@ -186,6 +187,12 @@ export class UserModel extends Model { }) NotificationSetting: UserNotificationSettingModel + @HasMany(() => VideoImportModel, { + foreignKey: 'userId', + onDelete: 'cascade' + }) + VideoImports: VideoImportModel[] + @HasMany(() => OAuthTokenModel, { foreignKey: 'userId', onDelete: 'cascade' @@ -400,6 +407,23 @@ export class UserModel extends Model { return UserModel.findOne(query) } + static loadByVideoImportId (videoImportId: number) { + const query = { + include: [ + { + required: true, + attributes: [ 'id' ], + model: VideoImportModel.unscoped(), + where: { + id: videoImportId + } + } + ] + } + + return UserModel.findOne(query) + } + static getOriginalVideoFileTotalFromUser (user: UserModel) { // Don't use sequelize because we need to use a sub query const query = UserModel.generateUserQuotaBaseSQL() diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index 3fd2d5a99..0fd868cd6 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts @@ -1,4 +1,3 @@ -import { values } from 'lodash' import { AllowNull, BelongsTo, @@ -20,7 +19,6 @@ import { isVideoFileSizeValid, isVideoFPSResolutionValid } from '../../helpers/custom-validators/videos' -import { CONSTRAINTS_FIELDS } from '../../initializers' import { throwIfNotValid } from '../utils' import { VideoModel } from './video' import * as Sequelize from 'sequelize' diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts index 8d442b3f8..c723e57c0 100644 --- a/server/models/video/video-import.ts +++ b/server/models/video/video-import.ts @@ -144,6 +144,10 @@ export class VideoImportModel extends Model { }) } + getTargetIdentifier () { + return this.targetUrl || this.magnetUri || this.torrentName + } + toFormattedJSON (): VideoImport { const videoFormatOptions = { completeDescription: true, diff --git a/server/models/video/video.ts b/server/models/video/video.ts index fc200e5d1..80a6c7832 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -94,6 +94,7 @@ import { import * as validator from 'validator' import { UserVideoHistoryModel } from '../account/user-video-history' import { UserModel } from '../account/user' +import { VideoImportModel } from './video-import' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -785,6 +786,15 @@ export class VideoModel extends Model { }) VideoBlacklist: VideoBlacklistModel + @HasOne(() => VideoImportModel, { + foreignKey: { + name: 'videoId', + allowNull: true + }, + onDelete: 'set null' + }) + VideoImport: VideoImportModel + @HasMany(() => VideoCaptionModel, { foreignKey: { name: 'videoId', diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts index 3ae36ddb3..4f21f7b95 100644 --- a/server/tests/api/check-params/user-notifications.ts +++ b/server/tests/api/check-params/user-notifications.ts @@ -52,6 +52,18 @@ describe('Test user notifications API validators', function () { await checkBadSortPagination(server.url, path, server.accessToken) }) + it('Should fail with an incorrect unread parameter', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + unread: 'toto' + }, + token: server.accessToken, + statusCodeExpected: 200 + }) + }) + it('Should fail with a non authenticated user', async function () { await makeGetRequest({ url: server.url, @@ -125,7 +137,9 @@ describe('Test user notifications API validators', function () { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION + blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, + myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, + myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION } it('Should fail with missing fields', async function () { diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts index 09c0479fd..e4966dbf5 100644 --- a/server/tests/api/users/user-notifications.ts +++ b/server/tests/api/users/user-notifications.ts @@ -29,33 +29,46 @@ import { getLastNotification, getUserNotifications, markAsReadNotifications, - updateMyNotificationSettings + updateMyNotificationSettings, + checkVideoIsPublished, checkMyVideoImportIsFinished } from '../../../../shared/utils/users/user-notifications' -import { User, UserNotification, UserNotificationSettingValue } from '../../../../shared/models/users' +import { + User, + UserNotification, + UserNotificationSetting, + UserNotificationSettingValue, + UserNotificationType +} from '../../../../shared/models/users' import { MockSmtpServer } from '../../../../shared/utils/miscs/email' import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions' import { VideoPrivacy } from '../../../../shared/models/videos' -import { getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports' +import { getYoutubeVideoUrl, importVideo, getBadVideoUrl } from '../../../../shared/utils/videos/video-imports' import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments' +import * as uuidv4 from 'uuid/v4' +import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist' const expect = chai.expect -async function uploadVideoByRemoteAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) { - const data = Object.assign({ name: 'remote video ' + videoNameId }, additionalParams) +async function uploadVideoByRemoteAccount (servers: ServerInfo[], additionalParams: any = {}) { + const name = 'remote video ' + uuidv4() + + const data = Object.assign({ name }, additionalParams) const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, data) await waitJobs(servers) - return res.body.video.uuid + return { uuid: res.body.video.uuid, name } } -async function uploadVideoByLocalAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) { - const data = Object.assign({ name: 'local video ' + videoNameId }, additionalParams) +async function uploadVideoByLocalAccount (servers: ServerInfo[], additionalParams: any = {}) { + const name = 'local video ' + uuidv4() + + const data = Object.assign({ name }, additionalParams) const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, data) await waitJobs(servers) - return res.body.video.uuid + return { uuid: res.body.video.uuid, name } } describe('Test users notifications', function () { @@ -63,7 +76,18 @@ describe('Test users notifications', function () { let userAccessToken: string let userNotifications: UserNotification[] = [] let adminNotifications: UserNotification[] = [] + let adminNotificationsServer2: UserNotification[] = [] const emails: object[] = [] + let channelId: number + + const allNotificationSettings: UserNotificationSetting = { + myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, + myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, + newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, + newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, + videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, + blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL + } before(async function () { this.timeout(120000) @@ -94,12 +118,9 @@ describe('Test users notifications', function () { await createUser(servers[0].url, servers[0].accessToken, user.username, user.password, 10 * 1000 * 1000) userAccessToken = await userLogin(servers[0], user) - await updateMyNotificationSettings(servers[0].url, userAccessToken, { - newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL - }) + await updateMyNotificationSettings(servers[0].url, userAccessToken, allNotificationSettings) + await updateMyNotificationSettings(servers[0].url, servers[0].accessToken, allNotificationSettings) + await updateMyNotificationSettings(servers[1].url, servers[1].accessToken, allNotificationSettings) { const socket = getUserNotificationSocket(servers[ 0 ].url, userAccessToken) @@ -109,6 +130,15 @@ describe('Test users notifications', function () { const socket = getUserNotificationSocket(servers[ 0 ].url, servers[0].accessToken) socket.on('new-notification', n => adminNotifications.push(n)) } + { + const socket = getUserNotificationSocket(servers[ 1 ].url, servers[1].accessToken) + socket.on('new-notification', n => adminNotificationsServer2.push(n)) + } + + { + const resChannel = await getMyUserInformation(servers[0].url, servers[0].accessToken) + channelId = resChannel.body.videoChannels[0].id + } }) describe('New video from my subscription notification', function () { @@ -124,7 +154,7 @@ describe('Test users notifications', function () { }) it('Should not send notifications if the user does not follow the video publisher', async function () { - await uploadVideoByLocalAccount(servers, 1) + await uploadVideoByLocalAccount(servers) const notification = await getLastNotification(servers[ 0 ].url, userAccessToken) expect(notification).to.be.undefined @@ -136,11 +166,8 @@ describe('Test users notifications', function () { it('Should send a new video notification if the user follows the local video publisher', async function () { await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001') - const videoNameId = 10 - const videoName = 'local video ' + videoNameId - - const uuid = await uploadVideoByLocalAccount(servers, videoNameId) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + const { name, uuid } = await uploadVideoByLocalAccount(servers) + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) it('Should send a new video notification from a remote account', async function () { @@ -148,21 +175,13 @@ describe('Test users notifications', function () { await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002') - const videoNameId = 20 - const videoName = 'remote video ' + videoNameId - - const uuid = await uploadVideoByRemoteAccount(servers, videoNameId) - await waitJobs(servers) - - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + const { name, uuid } = await uploadVideoByRemoteAccount(servers) + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) it('Should send a new video notification on a scheduled publication', async function () { this.timeout(20000) - const videoNameId = 30 - const videoName = 'local video ' + videoNameId - // In 2 seconds let updateAt = new Date(new Date().getTime() + 2000) @@ -173,18 +192,15 @@ describe('Test users notifications', function () { privacy: VideoPrivacy.PUBLIC } } - const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data) + const { name, uuid } = await uploadVideoByLocalAccount(servers, data) await wait(6000) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) it('Should send a new video notification on a remote scheduled publication', async function () { this.timeout(20000) - const videoNameId = 40 - const videoName = 'remote video ' + videoNameId - // In 2 seconds let updateAt = new Date(new Date().getTime() + 2000) @@ -195,19 +211,16 @@ describe('Test users notifications', function () { privacy: VideoPrivacy.PUBLIC } } - const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data) + const { name, uuid } = await uploadVideoByRemoteAccount(servers, data) await waitJobs(servers) await wait(6000) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) it('Should not send a notification before the video is published', async function () { this.timeout(20000) - const videoNameId = 50 - const videoName = 'local video ' + videoNameId - let updateAt = new Date(new Date().getTime() + 100000) const data = { @@ -217,86 +230,70 @@ describe('Test users notifications', function () { privacy: VideoPrivacy.PUBLIC } } - const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data) + const { name, uuid } = await uploadVideoByLocalAccount(servers, data) await wait(6000) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence') }) it('Should send a new video notification when a video becomes public', async function () { this.timeout(10000) - const videoNameId = 60 - const videoName = 'local video ' + videoNameId - const data = { privacy: VideoPrivacy.PRIVATE } - const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data) + const { name, uuid } = await uploadVideoByLocalAccount(servers, data) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence') await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC }) await wait(500) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) it('Should send a new video notification when a remote video becomes public', async function () { this.timeout(20000) - const videoNameId = 70 - const videoName = 'remote video ' + videoNameId - const data = { privacy: VideoPrivacy.PRIVATE } - const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data) - await waitJobs(servers) + const { name, uuid } = await uploadVideoByRemoteAccount(servers, data) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence') await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC }) await waitJobs(servers) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) it('Should not send a new video notification when a video becomes unlisted', async function () { this.timeout(20000) - const videoNameId = 80 - const videoName = 'local video ' + videoNameId - const data = { privacy: VideoPrivacy.PRIVATE } - const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data) + const { name, uuid } = await uploadVideoByLocalAccount(servers, data) await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED }) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence') }) it('Should not send a new video notification when a remote video becomes unlisted', async function () { this.timeout(20000) - const videoNameId = 90 - const videoName = 'remote video ' + videoNameId - const data = { privacy: VideoPrivacy.PRIVATE } - const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data) - await waitJobs(servers) + const { name, uuid } = await uploadVideoByRemoteAccount(servers, data) await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED }) await waitJobs(servers) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'absence') }) it('Should send a new video notification after a video import', async function () { this.timeout(30000) - const resChannel = await getMyUserInformation(servers[0].url, servers[0].accessToken) - const channelId = resChannel.body.videoChannels[0].id - const videoName = 'local video 100' + const name = 'video import ' + uuidv4() const attributes = { - name: videoName, + name, channelId, privacy: VideoPrivacy.PUBLIC, targetUrl: getYoutubeVideoUrl() @@ -306,7 +303,7 @@ describe('Test users notifications', function () { await waitJobs(servers) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) }) @@ -348,6 +345,23 @@ describe('Test users notifications', function () { await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence') }) + it('Should not send a new comment notification if the account is muted', async function () { + this.timeout(10000) + + await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root') + + const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' }) + const uuid = resVideo.body.video.uuid + + const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment') + const commentId = resComment.body.comment.id + + await wait(500) + await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence') + + await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root') + }) + it('Should send a new comment notification after a local comment on my video', async function () { this.timeout(10000) @@ -425,23 +439,21 @@ describe('Test users notifications', function () { it('Should send a notification to moderators on local video abuse', async function () { this.timeout(10000) - const videoName = 'local video 110' - - const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName }) + const name = 'video for abuse ' + uuidv4() + const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) const uuid = resVideo.body.video.uuid await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason') await waitJobs(servers) - await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence') + await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence') }) it('Should send a notification to moderators on remote video abuse', async function () { this.timeout(10000) - const videoName = 'remote video 120' - - const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName }) + const name = 'video for abuse ' + uuidv4() + const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) const uuid = resVideo.body.video.uuid await waitJobs(servers) @@ -449,7 +461,7 @@ describe('Test users notifications', function () { await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason') await waitJobs(servers) - await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence') + await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence') }) }) @@ -468,23 +480,21 @@ describe('Test users notifications', function () { it('Should send a notification to video owner on blacklist', async function () { this.timeout(10000) - const videoName = 'local video 130' - - const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName }) + const name = 'video for abuse ' + uuidv4() + const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) const uuid = resVideo.body.video.uuid await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid) await waitJobs(servers) - await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'blacklist') + await checkNewBlacklistOnMyVideo(baseParams, uuid, name, 'blacklist') }) it('Should send a notification to video owner on unblacklist', async function () { this.timeout(10000) - const videoName = 'local video 130' - - const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName }) + const name = 'video for abuse ' + uuidv4() + const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) const uuid = resVideo.body.video.uuid await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid) @@ -494,38 +504,187 @@ describe('Test users notifications', function () { await waitJobs(servers) await wait(500) - await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'unblacklist') + await checkNewBlacklistOnMyVideo(baseParams, uuid, name, 'unblacklist') + }) + }) + + describe('My video is published', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[1], + emails, + socketNotifications: adminNotificationsServer2, + token: servers[1].accessToken + } + }) + + it('Should not send a notification if transcoding is not enabled', async function () { + const { name, uuid } = await uploadVideoByLocalAccount(servers) + await waitJobs(servers) + + await checkVideoIsPublished(baseParams, name, uuid, 'absence') + }) + + it('Should not send a notification if the wait transcoding is false', async function () { + this.timeout(50000) + + await uploadVideoByRemoteAccount(servers, { waitTranscoding: false }) + await waitJobs(servers) + + const notification = await getLastNotification(servers[ 0 ].url, userAccessToken) + if (notification) { + expect(notification.type).to.not.equal(UserNotificationType.MY_VIDEO_PUBLISHED) + } + }) + + it('Should send a notification even if the video is not transcoded in other resolutions', async function () { + this.timeout(50000) + + const { name, uuid } = await uploadVideoByRemoteAccount(servers, { waitTranscoding: true, fixture: 'video_short_240p.mp4' }) + await waitJobs(servers) + + await checkVideoIsPublished(baseParams, name, uuid, 'presence') + }) + + it('Should send a notification with a transcoded video', async function () { + this.timeout(50000) + + const { name, uuid } = await uploadVideoByRemoteAccount(servers, { waitTranscoding: true }) + await waitJobs(servers) + + await checkVideoIsPublished(baseParams, name, uuid, 'presence') + }) + + it('Should send a notification when an imported video is transcoded', async function () { + this.timeout(50000) + + const name = 'video import ' + uuidv4() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PUBLIC, + targetUrl: getYoutubeVideoUrl(), + waitTranscoding: true + } + const res = await importVideo(servers[1].url, servers[1].accessToken, attributes) + const uuid = res.body.video.uuid + + await waitJobs(servers) + await checkVideoIsPublished(baseParams, name, uuid, 'presence') + }) + + it('Should send a notification when the scheduled update has been proceeded', async function () { + this.timeout(70000) + + // In 2 seconds + let updateAt = new Date(new Date().getTime() + 2000) + + const data = { + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + const { name, uuid } = await uploadVideoByRemoteAccount(servers, data) + + await wait(6000) + await checkVideoIsPublished(baseParams, name, uuid, 'presence') + }) + }) + + describe('My video is imported', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[0], + emails, + socketNotifications: adminNotifications, + token: servers[0].accessToken + } + }) + + it('Should send a notification when the video import failed', async function () { + this.timeout(70000) + + const name = 'video import ' + uuidv4() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PRIVATE, + targetUrl: getBadVideoUrl() + } + const res = await importVideo(servers[0].url, servers[0].accessToken, attributes) + const uuid = res.body.video.uuid + + await waitJobs(servers) + await checkMyVideoImportIsFinished(baseParams, name, uuid, getBadVideoUrl(), false, 'presence') + }) + + it('Should send a notification when the video import succeeded', async function () { + this.timeout(70000) + + const name = 'video import ' + uuidv4() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PRIVATE, + targetUrl: getYoutubeVideoUrl() + } + const res = await importVideo(servers[0].url, servers[0].accessToken, attributes) + const uuid = res.body.video.uuid + + await waitJobs(servers) + await checkMyVideoImportIsFinished(baseParams, name, uuid, getYoutubeVideoUrl(), true, 'presence') }) }) describe('Mark as read', function () { it('Should mark as read some notifications', async function () { - const res = await getUserNotifications(servers[0].url, userAccessToken, 2, 3) + const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3) const ids = res.body.data.map(n => n.id) - await markAsReadNotifications(servers[0].url, userAccessToken, ids) + await markAsReadNotifications(servers[ 0 ].url, userAccessToken, ids) }) it('Should have the notifications marked as read', async function () { - const res = await getUserNotifications(servers[0].url, userAccessToken, 0, 10) + const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10) + + const notifications = res.body.data as UserNotification[] + expect(notifications[ 0 ].read).to.be.false + expect(notifications[ 1 ].read).to.be.false + expect(notifications[ 2 ].read).to.be.true + expect(notifications[ 3 ].read).to.be.true + expect(notifications[ 4 ].read).to.be.true + expect(notifications[ 5 ].read).to.be.false + }) + + it('Should only list read notifications', async function () { + const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, false) const notifications = res.body.data as UserNotification[] - expect(notifications[0].read).to.be.false - expect(notifications[1].read).to.be.false - expect(notifications[2].read).to.be.true - expect(notifications[3].read).to.be.true - expect(notifications[4].read).to.be.true - expect(notifications[5].read).to.be.false + for (const notification of notifications) { + expect(notification.read).to.be.true + } + }) + + it('Should only list unread notifications', async function () { + const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 0, 10, true) + + const notifications = res.body.data as UserNotification[] + for (const notification of notifications) { + expect(notification.read).to.be.false + } }) }) describe('Notification settings', function () { - const baseUpdateNotificationParams = { - newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL - } let baseParams: CheckerBaseParams before(() => { @@ -538,7 +697,7 @@ describe('Test users notifications', function () { }) it('Should not have notifications', async function () { - await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, { + await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { newVideoFromSubscription: UserNotificationSettingValue.NONE })) @@ -548,16 +707,14 @@ describe('Test users notifications', function () { expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.NONE) } - const videoNameId = 42 - const videoName = 'local video ' + videoNameId - const uuid = await uploadVideoByLocalAccount(servers, videoNameId) + const { name, uuid } = await uploadVideoByLocalAccount(servers) const check = { web: true, mail: true } - await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence') + await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'absence') }) it('Should only have web notifications', async function () { - await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, { + await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION })) @@ -567,23 +724,21 @@ describe('Test users notifications', function () { expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION) } - const videoNameId = 52 - const videoName = 'local video ' + videoNameId - const uuid = await uploadVideoByLocalAccount(servers, videoNameId) + const { name, uuid } = await uploadVideoByLocalAccount(servers) { const check = { mail: true, web: false } - await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence') + await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'absence') } { const check = { mail: false, web: true } - await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence') + await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'presence') } }) it('Should only have mail notifications', async function () { - await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, { + await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { newVideoFromSubscription: UserNotificationSettingValue.EMAIL })) @@ -593,23 +748,21 @@ describe('Test users notifications', function () { expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.EMAIL) } - const videoNameId = 62 - const videoName = 'local video ' + videoNameId - const uuid = await uploadVideoByLocalAccount(servers, videoNameId) + const { name, uuid } = await uploadVideoByLocalAccount(servers) { const check = { mail: false, web: true } - await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence') + await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'absence') } { const check = { mail: true, web: false } - await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence') + await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), name, uuid, 'presence') } }) it('Should have email and web notifications', async function () { - await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, { + await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL })) @@ -619,11 +772,9 @@ describe('Test users notifications', function () { expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL) } - const videoNameId = 72 - const videoName = 'local video ' + videoNameId - const uuid = await uploadVideoByLocalAccount(servers, videoNameId) + const { name, uuid } = await uploadVideoByLocalAccount(servers) - await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence') + await checkNewVideoFromSubscription(baseParams, name, uuid, 'presence') }) }) diff --git a/server/tests/fixtures/video_short_240p.mp4 b/server/tests/fixtures/video_short_240p.mp4 new file mode 100644 index 000000000..db074940b Binary files /dev/null and b/server/tests/fixtures/video_short_240p.mp4 differ diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts index 7cecd70a2..55d351abf 100644 --- a/shared/models/users/user-notification-setting.model.ts +++ b/shared/models/users/user-notification-setting.model.ts @@ -10,4 +10,6 @@ export interface UserNotificationSetting { newCommentOnMyVideo: UserNotificationSettingValue videoAbuseAsModerator: UserNotificationSettingValue blacklistOnMyVideo: UserNotificationSettingValue + myVideoPublished: UserNotificationSettingValue + myVideoImportFinished: UserNotificationSettingValue } diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts index 39beb2350..ee9ac275a 100644 --- a/shared/models/users/user-notification.model.ts +++ b/shared/models/users/user-notification.model.ts @@ -3,10 +3,13 @@ export enum UserNotificationType { NEW_COMMENT_ON_MY_VIDEO = 2, NEW_VIDEO_ABUSE_FOR_MODERATORS = 3, BLACKLIST_ON_MY_VIDEO = 4, - UNBLACKLIST_ON_MY_VIDEO = 5 + UNBLACKLIST_ON_MY_VIDEO = 5, + MY_VIDEO_PUBLISHED = 6, + MY_VIDEO_IMPORT_SUCCESS = 7, + MY_VIDEO_IMPORT_ERROR = 8 } -interface VideoInfo { +export interface VideoInfo { id: number uuid: string name: string @@ -24,12 +27,22 @@ export interface UserNotification { } } + videoImport?: { + id: number + video?: VideoInfo + torrentName?: string + magnetUri?: string + targetUrl?: string + } + comment?: { id: number + threadId: number account: { id: number displayName: string } + video: VideoInfo } videoAbuse?: { diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts index dbe87559e..75d52023a 100644 --- a/shared/utils/users/user-notifications.ts +++ b/shared/utils/users/user-notifications.ts @@ -4,6 +4,7 @@ import { makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requ import { UserNotification, UserNotificationSetting, UserNotificationType } from '../../models/users' import { ServerInfo } from '..' import { expect } from 'chai' +import { inspect } from 'util' function updateMyNotificationSettings (url: string, token: string, settings: UserNotificationSetting, statusCodeExpected = 204) { const path = '/api/v1/users/me/notification-settings' @@ -17,7 +18,15 @@ function updateMyNotificationSettings (url: string, token: string, settings: Use }) } -function getUserNotifications (url: string, token: string, start: number, count: number, sort = '-createdAt', statusCodeExpected = 200) { +function getUserNotifications ( + url: string, + token: string, + start: number, + count: number, + unread?: boolean, + sort = '-createdAt', + statusCodeExpected = 200 +) { const path = '/api/v1/users/me/notifications' return makeGetRequest({ @@ -27,7 +36,8 @@ function getUserNotifications (url: string, token: string, start: number, count: query: { start, count, - sort + sort, + unread }, statusCodeExpected }) @@ -46,7 +56,7 @@ function markAsReadNotifications (url: string, token: string, ids: number[], sta } async function getLastNotification (serverUrl: string, accessToken: string) { - const res = await getUserNotifications(serverUrl, accessToken, 0, 1, '-createdAt') + const res = await getUserNotifications(serverUrl, accessToken, 0, 1, undefined, '-createdAt') if (res.body.total === 0) return undefined @@ -65,21 +75,33 @@ type CheckerType = 'presence' | 'absence' async function checkNotification ( base: CheckerBaseParams, - lastNotificationChecker: (notification: UserNotification) => void, - socketNotificationFinder: (notification: UserNotification) => boolean, + notificationChecker: (notification: UserNotification, type: CheckerType) => void, emailNotificationFinder: (email: object) => boolean, - checkType: 'presence' | 'absence' + checkType: CheckerType ) { const check = base.check || { web: true, mail: true } if (check.web) { const notification = await getLastNotification(base.server.url, base.token) - lastNotificationChecker(notification) - const socketNotification = base.socketNotifications.find(n => socketNotificationFinder(n)) + if (notification || checkType !== 'absence') { + notificationChecker(notification, checkType) + } - if (checkType === 'presence') expect(socketNotification, 'The socket notification is absent.').to.not.be.undefined - else expect(socketNotification, 'The socket notification is present.').to.be.undefined + const socketNotification = base.socketNotifications.find(n => { + try { + notificationChecker(n, 'presence') + return true + } catch { + return false + } + }) + + if (checkType === 'presence') { + expect(socketNotification, 'The socket notification is absent. ' + inspect(base.socketNotifications)).to.not.be.undefined + } else { + expect(socketNotification, 'The socket notification is present. ' + inspect(socketNotification)).to.be.undefined + } } if (check.mail) { @@ -89,45 +111,127 @@ async function checkNotification ( .reverse() .find(e => emailNotificationFinder(e)) - if (checkType === 'presence') expect(email, 'The email is present.').to.not.be.undefined - else expect(email, 'The email is absent.').to.be.undefined + if (checkType === 'presence') { + expect(email, 'The email is absent. ' + inspect(base.emails)).to.not.be.undefined + } else { + expect(email, 'The email is present. ' + inspect(email)).to.be.undefined + } } } +function checkVideo (video: any, videoName?: string, videoUUID?: string) { + expect(video.name).to.be.a('string') + expect(video.name).to.not.be.empty + if (videoName) expect(video.name).to.equal(videoName) + + expect(video.uuid).to.be.a('string') + expect(video.uuid).to.not.be.empty + if (videoUUID) expect(video.uuid).to.equal(videoUUID) + + expect(video.id).to.be.a('number') +} + +function checkActor (channel: any) { + expect(channel.id).to.be.a('number') + expect(channel.displayName).to.be.a('string') + expect(channel.displayName).to.not.be.empty +} + +function checkComment (comment: any, commentId: number, threadId: number) { + expect(comment.id).to.equal(commentId) + expect(comment.threadId).to.equal(threadId) +} + async function checkNewVideoFromSubscription (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) { const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION - function lastNotificationChecker (notification: UserNotification) { + function notificationChecker (notification: UserNotification, type: CheckerType) { if (type === 'presence') { expect(notification).to.not.be.undefined expect(notification.type).to.equal(notificationType) - expect(notification.video.name).to.equal(videoName) + + checkVideo(notification.video, videoName, videoUUID) + checkActor(notification.video.channel) } else { expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) } } - function socketFinder (notification: UserNotification) { - return notification.type === notificationType && notification.video.name === videoName + function emailFinder (email: object) { + return email[ 'text' ].indexOf(videoUUID) !== -1 + } + + await checkNotification(base, notificationChecker, emailFinder, type) +} + +async function checkVideoIsPublished (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) { + const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED + + function notificationChecker (notification: UserNotification, type: CheckerType) { + if (type === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, videoUUID) + checkActor(notification.video.channel) + } else { + expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) + } } function emailFinder (email: object) { - return email[ 'text' ].indexOf(videoUUID) !== -1 + const text: string = email[ 'text' ] + return text.includes(videoUUID) && text.includes('Your video') } - await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type) + await checkNotification(base, notificationChecker, emailFinder, type) +} + +async function checkMyVideoImportIsFinished ( + base: CheckerBaseParams, + videoName: string, + videoUUID: string, + url: string, + success: boolean, + type: CheckerType +) { + const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR + + function notificationChecker (notification: UserNotification, type: CheckerType) { + if (type === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoImport.targetUrl).to.equal(url) + + if (success) checkVideo(notification.videoImport.video, videoName, videoUUID) + } else { + expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url) + } + } + + function emailFinder (email: object) { + const text: string = email[ 'text' ] + const toFind = success ? ' finished' : ' error' + + return text.includes(url) && text.includes(toFind) + } + + await checkNotification(base, notificationChecker, emailFinder, type) } let lastEmailCount = 0 async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) { const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO - function lastNotificationChecker (notification: UserNotification) { + function notificationChecker (notification: UserNotification, type: CheckerType) { if (type === 'presence') { expect(notification).to.not.be.undefined expect(notification.type).to.equal(notificationType) - expect(notification.comment.id).to.equal(commentId) - expect(notification.comment.account.displayName).to.equal('root') + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + checkVideo(notification.comment.video, undefined, uuid) } else { expect(notification).to.satisfy((n: UserNotification) => { return n === undefined || n.comment === undefined || n.comment.id !== commentId @@ -135,18 +239,12 @@ async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, } } - function socketFinder (notification: UserNotification) { - return notification.type === notificationType && - notification.comment.id === commentId && - notification.comment.account.displayName === 'root' - } - const commentUrl = `http://localhost:9001/videos/watch/${uuid};threadId=${threadId}` function emailFinder (email: object) { return email[ 'text' ].indexOf(commentUrl) !== -1 } - await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type) + await checkNotification(base, notificationChecker, emailFinder, type) if (type === 'presence') { // We cannot detect email duplicates, so check we received another email @@ -158,12 +256,13 @@ async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) { const notificationType = UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS - function lastNotificationChecker (notification: UserNotification) { + function notificationChecker (notification: UserNotification, type: CheckerType) { if (type === 'presence') { expect(notification).to.not.be.undefined expect(notification.type).to.equal(notificationType) - expect(notification.videoAbuse.video.uuid).to.equal(videoUUID) - expect(notification.videoAbuse.video.name).to.equal(videoName) + + expect(notification.videoAbuse.id).to.be.a('number') + checkVideo(notification.videoAbuse.video, videoName, videoUUID) } else { expect(notification).to.satisfy((n: UserNotification) => { return n === undefined || n.videoAbuse === undefined || n.videoAbuse.video.uuid !== videoUUID @@ -171,16 +270,12 @@ async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUU } } - function socketFinder (notification: UserNotification) { - return notification.type === notificationType && notification.videoAbuse.video.uuid === videoUUID - } - function emailFinder (email: object) { const text = email[ 'text' ] return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1 } - await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type) + await checkNotification(base, notificationChecker, emailFinder, type) } async function checkNewBlacklistOnMyVideo ( @@ -193,18 +288,13 @@ async function checkNewBlacklistOnMyVideo ( ? UserNotificationType.BLACKLIST_ON_MY_VIDEO : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO - function lastNotificationChecker (notification: UserNotification) { + function notificationChecker (notification: UserNotification) { expect(notification).to.not.be.undefined expect(notification.type).to.equal(notificationType) const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video - expect(video.uuid).to.equal(videoUUID) - expect(video.name).to.equal(videoName) - } - - function socketFinder (notification: UserNotification) { - return notification.type === notificationType && (notification.video || notification.videoBlacklist.video).uuid === videoUUID + checkVideo(video, videoName, videoUUID) } function emailFinder (email: object) { @@ -212,7 +302,7 @@ async function checkNewBlacklistOnMyVideo ( return text.indexOf(videoUUID) !== -1 && text.indexOf(' ' + blacklistType) !== -1 } - await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, 'presence') + await checkNotification(base, notificationChecker, emailFinder, 'presence') } // --------------------------------------------------------------------------- @@ -221,6 +311,8 @@ export { CheckerBaseParams, CheckerType, checkNotification, + checkMyVideoImportIsFinished, + checkVideoIsPublished, checkNewVideoFromSubscription, checkNewCommentOnMyVideo, checkNewBlacklistOnMyVideo, diff --git a/shared/utils/videos/video-imports.ts b/shared/utils/videos/video-imports.ts index 3fa49b432..ec77cdcda 100644 --- a/shared/utils/videos/video-imports.ts +++ b/shared/utils/videos/video-imports.ts @@ -11,6 +11,10 @@ function getMagnetURI () { return 'magnet:?xs=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Ftorrents%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.torrent&xt=urn:btih:0f498834733e8057ed5c6f2ee2b4efd8d84a76ee&dn=super+peertube2+video&tr=wss%3A%2F%2Fpeertube2.cpy.re%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube2.cpy.re%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Fwebseed%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.mp4' } +function getBadVideoUrl () { + return 'https://download.cpy.re/peertube/bad_video.mp4' +} + function importVideo (url: string, token: string, attributes: VideoImportCreate) { const path = '/api/v1/videos/imports' @@ -45,6 +49,7 @@ function getMyVideoImports (url: string, token: string, sort?: string) { // --------------------------------------------------------------------------- export { + getBadVideoUrl, getYoutubeVideoUrl, importVideo, getMagnetURI,