From d38b82810638b9f664c9016fac2684454c273a77 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 8 Mar 2017 21:35:43 +0100 Subject: [PATCH] Add like/dislike system for videos --- client/src/app/shared/users/user.service.ts | 2 +- client/src/app/videos/shared/index.ts | 1 + .../src/app/videos/shared/rate-type.type.ts | 1 + .../src/app/videos/shared/sort-field.type.ts | 6 +- client/src/app/videos/shared/video.model.ts | 8 +- client/src/app/videos/shared/video.service.ts | 43 ++++- .../video-watch/video-watch.component.html | 20 ++- .../video-watch/video-watch.component.scss | 28 +++ .../video-watch/video-watch.component.ts | 70 +++++++- server/controllers/api/remote/videos.js | 27 ++- server/controllers/api/users.js | 27 ++- server/controllers/api/videos.js | 162 +++++++++++++++++- .../custom-validators/remote/videos.js | 4 +- server/helpers/custom-validators/videos.js | 6 + server/initializers/constants.js | 10 +- .../migrations/0020-video-likes.js | 19 ++ .../migrations/0025-video-dislikes.js | 19 ++ server/lib/friends.js | 43 ++++- server/lib/request-video-qadu-scheduler.js | 7 +- server/middlewares/validators/users.js | 22 ++- server/middlewares/validators/videos.js | 15 +- server/models/request-video-event.js | 3 + server/models/user-video-rate.js | 77 +++++++++ server/models/video.js | 31 +++- server/tests/api/check-params/users.js | 76 ++++++++ server/tests/api/check-params/videos.js | 42 +++++ server/tests/api/multiple-pods.js | 89 ++++++++-- server/tests/api/single-pod.js | 34 ++++ server/tests/api/users.js | 29 ++++ server/tests/utils/users.js | 13 ++ server/tests/utils/videos.js | 20 ++- 31 files changed, 907 insertions(+), 47 deletions(-) create mode 100644 client/src/app/videos/shared/rate-type.type.ts create mode 100644 server/initializers/migrations/0020-video-likes.js create mode 100644 server/initializers/migrations/0025-video-dislikes.js create mode 100644 server/models/user-video-rate.js diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index 4cf100f0d..865e04d48 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts @@ -8,7 +8,7 @@ import { RestExtractor } from '../rest'; @Injectable() export class UserService { - private static BASE_USERS_URL = '/api/v1/users/'; + static BASE_USERS_URL = '/api/v1/users/'; constructor( private authHttp: AuthHttp, diff --git a/client/src/app/videos/shared/index.ts b/client/src/app/videos/shared/index.ts index 67d16ead1..beaa528c0 100644 --- a/client/src/app/videos/shared/index.ts +++ b/client/src/app/videos/shared/index.ts @@ -1,4 +1,5 @@ export * from './loader'; export * from './sort-field.type'; +export * from './rate-type.type'; export * from './video.model'; export * from './video.service'; diff --git a/client/src/app/videos/shared/rate-type.type.ts b/client/src/app/videos/shared/rate-type.type.ts new file mode 100644 index 000000000..88034d1ff --- /dev/null +++ b/client/src/app/videos/shared/rate-type.type.ts @@ -0,0 +1 @@ +export type RateType = 'like' | 'dislike'; diff --git a/client/src/app/videos/shared/sort-field.type.ts b/client/src/app/videos/shared/sort-field.type.ts index 74908e344..7bda3112a 100644 --- a/client/src/app/videos/shared/sort-field.type.ts +++ b/client/src/app/videos/shared/sort-field.type.ts @@ -1,3 +1,3 @@ -export type SortField = "name" | "-name" - | "duration" | "-duration" - | "createdAt" | "-createdAt"; +export type SortField = 'name' | '-name' + | 'duration' | '-duration' + | 'createdAt' | '-createdAt'; diff --git a/client/src/app/videos/shared/video.model.ts b/client/src/app/videos/shared/video.model.ts index 8e676708b..3eef936eb 100644 --- a/client/src/app/videos/shared/video.model.ts +++ b/client/src/app/videos/shared/video.model.ts @@ -12,6 +12,8 @@ export class Video { tags: string[]; thumbnailPath: string; views: number; + likes: number; + dislikes: number; private static createByString(author: string, podHost: string) { return author + '@' + podHost; @@ -38,7 +40,9 @@ export class Video { podHost: string, tags: string[], thumbnailPath: string, - views: number + views: number, + likes: number, + dislikes: number, }) { this.author = hash.author; this.createdAt = new Date(hash.createdAt); @@ -52,6 +56,8 @@ export class Video { this.tags = hash.tags; this.thumbnailPath = hash.thumbnailPath; this.views = hash.views; + this.likes = hash.likes; + this.dislikes = hash.dislikes; this.by = Video.createByString(hash.author, hash.podHost); } diff --git a/client/src/app/videos/shared/video.service.ts b/client/src/app/videos/shared/video.service.ts index 7094d9a34..8bb5a2933 100644 --- a/client/src/app/videos/shared/video.service.ts +++ b/client/src/app/videos/shared/video.service.ts @@ -6,8 +6,16 @@ import 'rxjs/add/operator/map'; import { Search } from '../../shared'; import { SortField } from './sort-field.type'; +import { RateType } from './rate-type.type'; import { AuthService } from '../../core'; -import { AuthHttp, RestExtractor, RestPagination, RestService, ResultList } from '../../shared'; +import { + AuthHttp, + RestExtractor, + RestPagination, + RestService, + ResultList, + UserService +} from '../../shared'; import { Video } from './video.model'; @Injectable() @@ -56,14 +64,41 @@ export class VideoService { } reportVideo(id: string, reason: string) { + const url = VideoService.BASE_VIDEO_URL + id + '/abuse'; const body = { reason }; - const url = VideoService.BASE_VIDEO_URL + id + '/abuse'; return this.authHttp.post(url, body) - .map(this.restExtractor.extractDataBool) - .catch((res) => this.restExtractor.handleError(res)); + .map(this.restExtractor.extractDataBool) + .catch((res) => this.restExtractor.handleError(res)); + } + + setVideoLike(id: string) { + return this.setVideoRate(id, 'like'); + } + + setVideoDislike(id: string) { + return this.setVideoRate(id, 'dislike'); + } + + getUserVideoRating(id: string) { + const url = UserService.BASE_USERS_URL + '/me/videos/' + id + '/rating'; + + return this.authHttp.get(url) + .map(this.restExtractor.extractDataGet) + .catch((res) => this.restExtractor.handleError(res)); + } + + private setVideoRate(id: string, rateType: RateType) { + const url = VideoService.BASE_VIDEO_URL + id + '/rate'; + const body = { + rating: rateType + }; + + return this.authHttp.put(url, body) + .map(this.restExtractor.extractDataBool) + .catch((res) => this.restExtractor.handleError(res)); } private extractVideos(result: ResultList) { diff --git a/client/src/app/videos/video-watch/video-watch.component.html b/client/src/app/videos/video-watch/video-watch.component.html index 24d741ff9..67094359e 100644 --- a/client/src/app/videos/video-watch/video-watch.component.html +++ b/client/src/app/videos/video-watch/video-watch.component.html @@ -32,7 +32,7 @@
-
+
{{ video.name }} @@ -52,7 +52,23 @@
-
+
+
+ + + +
+ diff --git a/client/src/app/videos/video-watch/video-watch.component.scss b/client/src/app/videos/video-watch/video-watch.component.scss index 0b8af52ce..5f322a194 100644 --- a/client/src/app/videos/video-watch/video-watch.component.scss +++ b/client/src/app/videos/video-watch/video-watch.component.scss @@ -47,6 +47,34 @@ top: 2px; } + #rates { + display: inline-block; + margin-right: 20px; + + // Remove focus style + .btn:focus { + outline: 0; + } + + .activated-btn { + color: #333; + background-color: #e6e6e6; + border-color: #8c8c8c; + } + + .not-interactive-btn { + cursor: default; + + &:hover, &:focus, &:active { + color: #333; + background-color: #fff; + border-color: #ccc; + box-shadow: none; + outline: 0; + } + } + } + #share, #more { font-weight: bold; opacity: 0.85; diff --git a/client/src/app/videos/video-watch/video-watch.component.ts b/client/src/app/videos/video-watch/video-watch.component.ts index d1abc81bc..ed6b30102 100644 --- a/client/src/app/videos/video-watch/video-watch.component.ts +++ b/client/src/app/videos/video-watch/video-watch.component.ts @@ -10,7 +10,7 @@ import { AuthService } from '../../core'; import { VideoMagnetComponent } from './video-magnet.component'; import { VideoShareComponent } from './video-share.component'; import { VideoReportComponent } from './video-report.component'; -import { Video, VideoService } from '../shared'; +import { RateType, Video, VideoService } from '../shared'; import { WebTorrentService } from './webtorrent.service'; @Component({ @@ -33,6 +33,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { player: VideoJSPlayer; playerElement: Element; uploadSpeed: number; + userRating: RateType = null; video: Video = null; videoNotFound = false; @@ -61,6 +62,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.video = video; this.setOpenGraphTags(); this.loadVideo(); + this.checkUserRating(); }, error => { this.videoNotFound = true; @@ -136,6 +138,40 @@ export class VideoWatchComponent implements OnInit, OnDestroy { }); } + setLike() { + if (this.isUserLoggedIn() === false) return; + // Already liked this video + if (this.userRating === 'like') return; + + this.videoService.setVideoLike(this.video.id) + .subscribe( + () => { + // Update the video like attribute + this.updateVideoRating(this.userRating, 'like'); + this.userRating = 'like'; + }, + + err => this.notificationsService.error('Error', err.text) + ); + } + + setDislike() { + if (this.isUserLoggedIn() === false) return; + // Already disliked this video + if (this.userRating === 'dislike') return; + + this.videoService.setVideoDislike(this.video.id) + .subscribe( + () => { + // Update the video dislike attribute + this.updateVideoRating(this.userRating, 'dislike'); + this.userRating = 'dislike'; + }, + + err => this.notificationsService.error('Error', err.text) + ); + } + showReportModal(event: Event) { event.preventDefault(); this.videoReportModal.show(); @@ -154,6 +190,38 @@ export class VideoWatchComponent implements OnInit, OnDestroy { return this.authService.isLoggedIn(); } + private checkUserRating() { + // Unlogged users do not have ratings + if (this.isUserLoggedIn() === false) return; + + this.videoService.getUserVideoRating(this.video.id) + .subscribe( + ratingObject => { + if (ratingObject) { + this.userRating = ratingObject.rating; + } + }, + + err => this.notificationsService.error('Error', err.text) + ); + } + + private updateVideoRating(oldRating: RateType, newRating: RateType) { + let likesToIncrement = 0; + let dislikesToIncrement = 0; + + if (oldRating) { + if (oldRating === 'like') likesToIncrement--; + if (oldRating === 'dislike') dislikesToIncrement--; + } + + if (newRating === 'like') likesToIncrement++; + if (newRating === 'dislike') dislikesToIncrement++; + + this.video.likes += likesToIncrement; + this.video.dislikes += dislikesToIncrement; + } + private loadTooLong() { this.error = true; console.error('The video load seems to be abnormally long.'); diff --git a/server/controllers/api/remote/videos.js b/server/controllers/api/remote/videos.js index 39c9579c1..98891c99e 100644 --- a/server/controllers/api/remote/videos.js +++ b/server/controllers/api/remote/videos.js @@ -11,6 +11,7 @@ const secureMiddleware = middlewares.secure const videosValidators = middlewares.validators.remote.videos const signatureValidators = middlewares.validators.remote.signature const logger = require('../../../helpers/logger') +const friends = require('../../../lib/friends') const databaseUtils = require('../../../helpers/database-utils') const ENDPOINT_ACTIONS = constants.REQUEST_ENDPOINT_ACTIONS[constants.REQUEST_ENDPOINTS.VIDEOS] @@ -129,18 +130,22 @@ function processVideosEvents (eventData, fromPod, finalCallback) { const options = { transaction: t } let columnToUpdate + let qaduType switch (eventData.eventType) { case constants.REQUEST_VIDEO_EVENT_TYPES.VIEWS: columnToUpdate = 'views' + qaduType = constants.REQUEST_VIDEO_QADU_TYPES.VIEWS break case constants.REQUEST_VIDEO_EVENT_TYPES.LIKES: columnToUpdate = 'likes' + qaduType = constants.REQUEST_VIDEO_QADU_TYPES.LIKES break case constants.REQUEST_VIDEO_EVENT_TYPES.DISLIKES: columnToUpdate = 'dislikes' + qaduType = constants.REQUEST_VIDEO_QADU_TYPES.DISLIKES break default: @@ -151,6 +156,19 @@ function processVideosEvents (eventData, fromPod, finalCallback) { query[columnToUpdate] = eventData.count videoInstance.increment(query, options).asCallback(function (err) { + return callback(err, t, videoInstance, qaduType) + }) + }, + + function sendQaduToFriends (t, videoInstance, qaduType, callback) { + const qadusParams = [ + { + videoId: videoInstance.id, + type: qaduType + } + ] + + friends.quickAndDirtyUpdatesVideoToFriends(qadusParams, t, function (err) { return callback(err, t) }) }, @@ -159,7 +177,6 @@ function processVideosEvents (eventData, fromPod, finalCallback) { ], function (err, t) { if (err) { - console.log(err) logger.debug('Cannot process a video event.', { error: err }) return databaseUtils.rollbackTransaction(err, t, finalCallback) } @@ -278,7 +295,10 @@ function addRemoteVideo (videoToCreateData, fromPod, finalCallback) { duration: videoToCreateData.duration, createdAt: videoToCreateData.createdAt, // FIXME: updatedAt does not seems to be considered by Sequelize - updatedAt: videoToCreateData.updatedAt + updatedAt: videoToCreateData.updatedAt, + views: videoToCreateData.views, + likes: videoToCreateData.likes, + dislikes: videoToCreateData.dislikes } const video = db.Video.build(videoData) @@ -372,6 +392,9 @@ function updateRemoteVideo (videoAttributesToUpdate, fromPod, finalCallback) { videoInstance.set('createdAt', videoAttributesToUpdate.createdAt) videoInstance.set('updatedAt', videoAttributesToUpdate.updatedAt) videoInstance.set('extname', videoAttributesToUpdate.extname) + videoInstance.set('views', videoAttributesToUpdate.views) + videoInstance.set('likes', videoAttributesToUpdate.likes) + videoInstance.set('dislikes', videoAttributesToUpdate.dislikes) videoInstance.save(options).asCallback(function (err) { return callback(err, t, videoInstance, tagInstances) diff --git a/server/controllers/api/users.js b/server/controllers/api/users.js index 324c99b4c..f854b3082 100644 --- a/server/controllers/api/users.js +++ b/server/controllers/api/users.js @@ -18,7 +18,16 @@ const validatorsUsers = middlewares.validators.users const router = express.Router() -router.get('/me', oAuth.authenticate, getUserInformation) +router.get('/me', + oAuth.authenticate, + getUserInformation +) + +router.get('/me/videos/:videoId/rating', + oAuth.authenticate, + validatorsUsers.usersVideoRating, + getUserVideoRating +) router.get('/', validatorsPagination.pagination, @@ -80,6 +89,22 @@ function getUserInformation (req, res, next) { }) } +function getUserVideoRating (req, res, next) { + const videoId = req.params.videoId + const userId = res.locals.oauth.token.User.id + + db.UserVideoRate.load(userId, videoId, function (err, ratingObj) { + if (err) return next(err) + + const rating = ratingObj ? ratingObj.type : 'none' + + res.json({ + videoId, + rating + }) + }) +} + function listUsers (req, res, next) { db.User.listForApi(req.query.start, req.query.count, req.query.sort, function (err, usersList, usersTotal) { if (err) return next(err) diff --git a/server/controllers/api/videos.js b/server/controllers/api/videos.js index 5a67d1121..9acdb8fd2 100644 --- a/server/controllers/api/videos.js +++ b/server/controllers/api/videos.js @@ -60,6 +60,12 @@ router.post('/:id/abuse', reportVideoAbuseRetryWrapper ) +router.put('/:id/rate', + oAuth.authenticate, + validatorsVideos.videoRate, + rateVideoRetryWrapper +) + router.get('/', validatorsPagination.pagination, validatorsSort.videosSort, @@ -104,6 +110,147 @@ module.exports = router // --------------------------------------------------------------------------- +function rateVideoRetryWrapper (req, res, next) { + const options = { + arguments: [ req, res ], + errorMessage: 'Cannot update the user video rate.' + } + + databaseUtils.retryTransactionWrapper(rateVideo, options, function (err) { + if (err) return next(err) + + return res.type('json').status(204).end() + }) +} + +function rateVideo (req, res, finalCallback) { + const rateType = req.body.rating + const videoInstance = res.locals.video + const userInstance = res.locals.oauth.token.User + + waterfall([ + databaseUtils.startSerializableTransaction, + + function findPreviousRate (t, callback) { + db.UserVideoRate.load(userInstance.id, videoInstance.id, t, function (err, previousRate) { + return callback(err, t, previousRate) + }) + }, + + function insertUserRateIntoDB (t, previousRate, callback) { + const options = { transaction: t } + + let likesToIncrement = 0 + let dislikesToIncrement = 0 + + if (rateType === constants.VIDEO_RATE_TYPES.LIKE) likesToIncrement++ + else if (rateType === constants.VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement++ + + // There was a previous rate, update it + if (previousRate) { + // We will remove the previous rate, so we will need to remove it from the video attribute + if (previousRate.type === constants.VIDEO_RATE_TYPES.LIKE) likesToIncrement-- + else if (previousRate.type === constants.VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement-- + + previousRate.type = rateType + + previousRate.save(options).asCallback(function (err) { + return callback(err, t, likesToIncrement, dislikesToIncrement) + }) + } else { // There was not a previous rate, insert a new one + const query = { + userId: userInstance.id, + videoId: videoInstance.id, + type: rateType + } + + db.UserVideoRate.create(query, options).asCallback(function (err) { + return callback(err, t, likesToIncrement, dislikesToIncrement) + }) + } + }, + + function updateVideoAttributeDB (t, likesToIncrement, dislikesToIncrement, callback) { + const options = { transaction: t } + const incrementQuery = { + likes: likesToIncrement, + dislikes: dislikesToIncrement + } + + // Even if we do not own the video we increment the attributes + // It is usefull for the user to have a feedback + videoInstance.increment(incrementQuery, options).asCallback(function (err) { + return callback(err, t, likesToIncrement, dislikesToIncrement) + }) + }, + + function sendEventsToFriendsIfNeeded (t, likesToIncrement, dislikesToIncrement, callback) { + // No need for an event type, we own the video + if (videoInstance.isOwned()) return callback(null, t, likesToIncrement, dislikesToIncrement) + + const eventsParams = [] + + if (likesToIncrement !== 0) { + eventsParams.push({ + videoId: videoInstance.id, + type: constants.REQUEST_VIDEO_EVENT_TYPES.LIKES, + count: likesToIncrement + }) + } + + if (dislikesToIncrement !== 0) { + eventsParams.push({ + videoId: videoInstance.id, + type: constants.REQUEST_VIDEO_EVENT_TYPES.DISLIKES, + count: dislikesToIncrement + }) + } + + friends.addEventsToRemoteVideo(eventsParams, t, function (err) { + return callback(err, t, likesToIncrement, dislikesToIncrement) + }) + }, + + function sendQaduToFriendsIfNeeded (t, likesToIncrement, dislikesToIncrement, callback) { + // We do not own the video, there is no need to send a quick and dirty update to friends + // Our rate was already sent by the addEvent function + if (videoInstance.isOwned() === false) return callback(null, t) + + const qadusParams = [] + + if (likesToIncrement !== 0) { + qadusParams.push({ + videoId: videoInstance.id, + type: constants.REQUEST_VIDEO_QADU_TYPES.LIKES + }) + } + + if (dislikesToIncrement !== 0) { + qadusParams.push({ + videoId: videoInstance.id, + type: constants.REQUEST_VIDEO_QADU_TYPES.DISLIKES + }) + } + + friends.quickAndDirtyUpdatesVideoToFriends(qadusParams, t, function (err) { + return callback(err, t) + }) + }, + + databaseUtils.commitTransaction + + ], function (err, t) { + if (err) { + // This is just a debug because we will retry the insert + logger.debug('Cannot add the user video rate.', { error: err }) + return databaseUtils.rollbackTransaction(err, t, finalCallback) + } + + logger.info('User video rate for video %s of user %s updated.', videoInstance.name, userInstance.username) + return finalCallback(null) + }) +} + // Wrapper to video add that retry the function if there is a database error // We need this because we run the transaction in SERIALIZABLE isolation that can fail function addVideoRetryWrapper (req, res, next) { @@ -155,8 +302,7 @@ function addVideo (req, res, videoFile, finalCallback) { extname: path.extname(videoFile.filename), description: videoInfos.description, duration: videoFile.duration, - authorId: author.id, - views: videoInfos.views + authorId: author.id } const video = db.Video.build(videoData) @@ -332,11 +478,19 @@ function getVideo (req, res, next) { // FIXME: make a real view system // For example, only add a view when a user watch a video during 30s etc - friends.quickAndDirtyUpdateVideoToFriends(videoInstance.id, constants.REQUEST_VIDEO_QADU_TYPES.VIEWS) + const qaduParams = { + videoId: videoInstance.id, + type: constants.REQUEST_VIDEO_QADU_TYPES.VIEWS + } + friends.quickAndDirtyUpdateVideoToFriends(qaduParams) }) } else { // Just send the event to our friends - friends.addEventToRemoteVideo(videoInstance.id, constants.REQUEST_VIDEO_EVENT_TYPES.VIEWS) + const eventParams = { + videoId: videoInstance.id, + type: constants.REQUEST_VIDEO_EVENT_TYPES.VIEWS + } + friends.addEventToRemoteVideo(eventParams) } // Do not wait the view system diff --git a/server/helpers/custom-validators/remote/videos.js b/server/helpers/custom-validators/remote/videos.js index ba2d0bb93..e1636e0e6 100644 --- a/server/helpers/custom-validators/remote/videos.js +++ b/server/helpers/custom-validators/remote/videos.js @@ -92,7 +92,9 @@ function isCommonVideoAttributesValid (video) { videosValidators.isVideoTagsValid(video.tags) && videosValidators.isVideoRemoteIdValid(video.remoteId) && videosValidators.isVideoExtnameValid(video.extname) && - videosValidators.isVideoViewsValid(video.views) + videosValidators.isVideoViewsValid(video.views) && + videosValidators.isVideoLikesValid(video.likes) && + videosValidators.isVideoDislikesValid(video.dislikes) } function isRequestTypeAddValid (value) { diff --git a/server/helpers/custom-validators/videos.js b/server/helpers/custom-validators/videos.js index c5a1f3cb5..648c7540b 100644 --- a/server/helpers/custom-validators/videos.js +++ b/server/helpers/custom-validators/videos.js @@ -1,6 +1,7 @@ 'use strict' const validator = require('express-validator').validator +const values = require('lodash/values') const constants = require('../../initializers/constants') const usersValidators = require('./users') @@ -26,6 +27,7 @@ const videosValidators = { isVideoFile, isVideoViewsValid, isVideoLikesValid, + isVideoRatingTypeValid, isVideoDislikesValid, isVideoEventCountValid } @@ -103,6 +105,10 @@ function isVideoEventCountValid (value) { return validator.isInt(value + '', VIDEO_EVENTS_CONSTRAINTS_FIELDS.COUNT) } +function isVideoRatingTypeValid (value) { + return values(constants.VIDEO_RATE_TYPES).indexOf(value) !== -1 +} + function isVideoFile (value, files) { // Should have files if (!files) return false diff --git a/server/initializers/constants.js b/server/initializers/constants.js index 2d5bb84cc..16a2dd320 100644 --- a/server/initializers/constants.js +++ b/server/initializers/constants.js @@ -5,7 +5,7 @@ const path = require('path') // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 15 +const LAST_MIGRATION_VERSION = 25 // --------------------------------------------------------------------------- @@ -95,6 +95,11 @@ const CONSTRAINTS_FIELDS = { } } +const VIDEO_RATE_TYPES = { + LIKE: 'like', + DISLIKE: 'dislike' +} + // --------------------------------------------------------------------------- // Score a pod has when we create it as a friend @@ -249,7 +254,8 @@ module.exports = { STATIC_MAX_AGE, STATIC_PATHS, THUMBNAILS_SIZE, - USER_ROLES + USER_ROLES, + VIDEO_RATE_TYPES } // --------------------------------------------------------------------------- diff --git a/server/initializers/migrations/0020-video-likes.js b/server/initializers/migrations/0020-video-likes.js new file mode 100644 index 000000000..6db62cb90 --- /dev/null +++ b/server/initializers/migrations/0020-video-likes.js @@ -0,0 +1,19 @@ +'use strict' + +// utils = { transaction, queryInterface, sequelize, Sequelize } +exports.up = function (utils, finalCallback) { + const q = utils.queryInterface + const Sequelize = utils.Sequelize + + const data = { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + } + + q.addColumn('Videos', 'likes', data, { transaction: utils.transaction }).asCallback(finalCallback) +} + +exports.down = function (options, callback) { + throw new Error('Not implemented.') +} diff --git a/server/initializers/migrations/0025-video-dislikes.js b/server/initializers/migrations/0025-video-dislikes.js new file mode 100644 index 000000000..40d2e7351 --- /dev/null +++ b/server/initializers/migrations/0025-video-dislikes.js @@ -0,0 +1,19 @@ +'use strict' + +// utils = { transaction, queryInterface, sequelize, Sequelize } +exports.up = function (utils, finalCallback) { + const q = utils.queryInterface + const Sequelize = utils.Sequelize + + const data = { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + } + + q.addColumn('Videos', 'dislikes', data, { transaction: utils.transaction }).asCallback(finalCallback) +} + +exports.down = function (options, callback) { + throw new Error('Not implemented.') +} diff --git a/server/lib/friends.js b/server/lib/friends.js index 7bd087d8c..23accfa45 100644 --- a/server/lib/friends.js +++ b/server/lib/friends.js @@ -3,6 +3,7 @@ const each = require('async/each') const eachLimit = require('async/eachLimit') const eachSeries = require('async/eachSeries') +const series = require('async/series') const request = require('request') const waterfall = require('async/waterfall') @@ -28,7 +29,9 @@ const friends = { updateVideoToFriends, reportAbuseVideoToFriend, quickAndDirtyUpdateVideoToFriends, + quickAndDirtyUpdatesVideoToFriends, addEventToRemoteVideo, + addEventsToRemoteVideo, hasFriends, makeFriends, quitFriends, @@ -84,24 +87,52 @@ function reportAbuseVideoToFriend (reportData, video) { createRequest(options) } -function quickAndDirtyUpdateVideoToFriends (videoId, type, transaction, callback) { +function quickAndDirtyUpdateVideoToFriends (qaduParams, transaction, callback) { const options = { - videoId, - type, + videoId: qaduParams.videoId, + type: qaduParams.type, transaction } return createVideoQaduRequest(options, callback) } -function addEventToRemoteVideo (videoId, type, transaction, callback) { +function quickAndDirtyUpdatesVideoToFriends (qadusParams, transaction, finalCallback) { + const tasks = [] + + qadusParams.forEach(function (qaduParams) { + const fun = function (callback) { + quickAndDirtyUpdateVideoToFriends(qaduParams, transaction, callback) + } + + tasks.push(fun) + }) + + series(tasks, finalCallback) +} + +function addEventToRemoteVideo (eventParams, transaction, callback) { const options = { - videoId, - type, + videoId: eventParams.videoId, + type: eventParams.type, transaction } createVideoEventRequest(options, callback) } +function addEventsToRemoteVideo (eventsParams, transaction, finalCallback) { + const tasks = [] + + eventsParams.forEach(function (eventParams) { + const fun = function (callback) { + addEventToRemoteVideo(eventParams, transaction, callback) + } + + tasks.push(fun) + }) + + series(tasks, finalCallback) +} + function hasFriends (callback) { db.Pod.countAll(function (err, count) { if (err) return callback(err) diff --git a/server/lib/request-video-qadu-scheduler.js b/server/lib/request-video-qadu-scheduler.js index ac50cfc11..a85d35160 100644 --- a/server/lib/request-video-qadu-scheduler.js +++ b/server/lib/request-video-qadu-scheduler.js @@ -44,14 +44,17 @@ module.exports = class RequestVideoQaduScheduler extends BaseRequestScheduler { } } - const videoData = {} + // Maybe another attribute was filled for this video + let videoData = requestsToMakeGrouped[hashKey].videos[video.id] + if (!videoData) videoData = {} + switch (request.type) { case constants.REQUEST_VIDEO_QADU_TYPES.LIKES: videoData.likes = video.likes break case constants.REQUEST_VIDEO_QADU_TYPES.DISLIKES: - videoData.likes = video.dislikes + videoData.dislikes = video.dislikes break case constants.REQUEST_VIDEO_QADU_TYPES.VIEWS: diff --git a/server/middlewares/validators/users.js b/server/middlewares/validators/users.js index 3089370ff..ce83fc074 100644 --- a/server/middlewares/validators/users.js +++ b/server/middlewares/validators/users.js @@ -7,7 +7,8 @@ const logger = require('../../helpers/logger') const validatorsUsers = { usersAdd, usersRemove, - usersUpdate + usersUpdate, + usersVideoRating } function usersAdd (req, res, next) { @@ -62,6 +63,25 @@ function usersUpdate (req, res, next) { checkErrors(req, res, next) } +function usersVideoRating (req, res, next) { + req.checkParams('videoId', 'Should have a valid video id').notEmpty().isUUID(4) + + logger.debug('Checking usersVideoRating parameters', { parameters: req.params }) + + checkErrors(req, res, function () { + db.Video.load(req.params.videoId, function (err, video) { + if (err) { + logger.error('Error in user request validator.', { error: err }) + return res.sendStatus(500) + } + + if (!video) return res.status(404).send('Video not found') + + next() + }) + }) +} + // --------------------------------------------------------------------------- module.exports = validatorsUsers diff --git a/server/middlewares/validators/videos.js b/server/middlewares/validators/videos.js index 5c3f3ecf3..7dc79c56f 100644 --- a/server/middlewares/validators/videos.js +++ b/server/middlewares/validators/videos.js @@ -13,7 +13,9 @@ const validatorsVideos = { videosRemove, videosSearch, - videoAbuseReport + videoAbuseReport, + + videoRate } function videosAdd (req, res, next) { @@ -119,6 +121,17 @@ function videoAbuseReport (req, res, next) { }) } +function videoRate (req, res, next) { + req.checkParams('id', 'Should have a valid id').notEmpty().isUUID(4) + req.checkBody('rating', 'Should have a valid rate type').isVideoRatingTypeValid() + + logger.debug('Checking videoRate parameters', { parameters: req.body }) + + checkErrors(req, res, function () { + checkVideoExists(req.params.id, res, next) + }) +} + // --------------------------------------------------------------------------- module.exports = validatorsVideos diff --git a/server/models/request-video-event.js b/server/models/request-video-event.js index ef3ebcb3a..9ebeaec90 100644 --- a/server/models/request-video-event.js +++ b/server/models/request-video-event.js @@ -83,6 +83,9 @@ function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { if (podIds.length === 0) return callback(null, []) const query = { + order: [ + [ 'id', 'ASC' ] + ], include: [ { model: self.sequelize.models.Video, diff --git a/server/models/user-video-rate.js b/server/models/user-video-rate.js new file mode 100644 index 000000000..84007d70c --- /dev/null +++ b/server/models/user-video-rate.js @@ -0,0 +1,77 @@ +'use strict' + +/* + User rates per video. + +*/ + +const values = require('lodash/values') + +const constants = require('../initializers/constants') + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const UserVideoRate = sequelize.define('UserVideoRate', + { + type: { + type: DataTypes.ENUM(values(constants.VIDEO_RATE_TYPES)), + allowNull: false + } + }, + { + indexes: [ + { + fields: [ 'videoId', 'userId', 'type' ], + unique: true + } + ], + classMethods: { + associate, + + load + } + } + ) + + return UserVideoRate +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + this.belongsTo(models.Video, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'CASCADE' + }) + + this.belongsTo(models.User, { + foreignKey: { + name: 'userId', + allowNull: false + }, + onDelete: 'CASCADE' + }) +} + +function load (userId, videoId, transaction, callback) { + if (!callback) { + callback = transaction + transaction = null + } + + const query = { + where: { + userId, + videoId + } + } + + const options = {} + if (transaction) options.transaction = transaction + + return this.findOne(query, options).asCallback(callback) +} diff --git a/server/models/video.js b/server/models/video.js index fb46aca86..182555c85 100644 --- a/server/models/video.js +++ b/server/models/video.js @@ -89,6 +89,24 @@ module.exports = function (sequelize, DataTypes) { min: 0, isInt: true } + }, + likes: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + validate: { + min: 0, + isInt: true + } + }, + dislikes: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + validate: { + min: 0, + isInt: true + } } }, { @@ -113,6 +131,9 @@ module.exports = function (sequelize, DataTypes) { }, { fields: [ 'views' ] + }, + { + fields: [ 'likes' ] } ], classMethods: { @@ -349,6 +370,8 @@ function toFormatedJSON () { author: this.Author.name, duration: this.duration, views: this.views, + likes: this.likes, + dislikes: this.dislikes, tags: map(this.Tags, 'name'), thumbnailPath: pathUtils.join(constants.STATIC_PATHS.THUMBNAILS, this.getThumbnailName()), createdAt: this.createdAt, @@ -381,7 +404,9 @@ function toAddRemoteJSON (callback) { createdAt: self.createdAt, updatedAt: self.updatedAt, extname: self.extname, - views: self.views + views: self.views, + likes: self.likes, + dislikes: self.dislikes } return callback(null, remoteVideo) @@ -400,7 +425,9 @@ function toUpdateRemoteJSON (callback) { createdAt: this.createdAt, updatedAt: this.updatedAt, extname: this.extname, - views: this.views + views: this.views, + likes: this.likes, + dislikes: this.dislikes } return json diff --git a/server/tests/api/check-params/users.js b/server/tests/api/check-params/users.js index 6edb54660..11e2bada4 100644 --- a/server/tests/api/check-params/users.js +++ b/server/tests/api/check-params/users.js @@ -9,11 +9,13 @@ const loginUtils = require('../../utils/login') const requestsUtils = require('../../utils/requests') const serversUtils = require('../../utils/servers') const usersUtils = require('../../utils/users') +const videosUtils = require('../../utils/videos') describe('Test users API validators', function () { const path = '/api/v1/users/' let userId = null let rootId = null + let videoId = null let server = null let userAccessToken = null @@ -47,6 +49,23 @@ describe('Test users API validators', function () { usersUtils.createUser(server.url, server.accessToken, username, password, next) }, + function (next) { + const name = 'my super name for pod' + const description = 'my super description for pod' + const tags = [ 'tag' ] + const file = 'video_short2.webm' + videosUtils.uploadVideo(server.url, server.accessToken, name, description, tags, file, next) + }, + function (next) { + videosUtils.getVideosList(server.url, function (err, res) { + if (err) throw err + + const videos = res.body.data + videoId = videos[0].id + + next() + }) + }, function (next) { const user = { username: 'user1', @@ -289,6 +308,63 @@ describe('Test users API validators', function () { }) }) + describe('When getting my video rating', function () { + it('Should fail with a non authenticated user', function (done) { + request(server.url) + .get(path + 'me/videos/' + videoId + '/rating') + .set('Authorization', 'Bearer faketoken') + .set('Accept', 'application/json') + .expect(401, done) + }) + + it('Should fail with an incorrect video uuid', function (done) { + request(server.url) + .get(path + 'me/videos/blabla/rating') + .set('Authorization', 'Bearer ' + userAccessToken) + .set('Accept', 'application/json') + .expect(400, done) + }) + + it('Should fail with an unknown video', function (done) { + request(server.url) + .get(path + 'me/videos/4da6fde3-88f7-4d16-b119-108df5630b06/rating') + .set('Authorization', 'Bearer ' + userAccessToken) + .set('Accept', 'application/json') + .expect(404, done) + }) + + it('Should success with the correct parameters', function (done) { + request(server.url) + .get(path + 'me/videos/' + videoId + '/rating') + .set('Authorization', 'Bearer ' + userAccessToken) + .set('Accept', 'application/json') + .expect(200, done) + }) + }) + + describe('When removing an user', function () { + it('Should fail with an incorrect id', function (done) { + request(server.url) + .delete(path + 'bla-bla') + .set('Authorization', 'Bearer ' + server.accessToken) + .expect(400, done) + }) + + it('Should fail with the root user', function (done) { + request(server.url) + .delete(path + rootId) + .set('Authorization', 'Bearer ' + server.accessToken) + .expect(400, done) + }) + + it('Should return 404 with a non existing id', function (done) { + request(server.url) + .delete(path + '45') + .set('Authorization', 'Bearer ' + server.accessToken) + .expect(404, done) + }) + }) + describe('When removing an user', function () { it('Should fail with an incorrect id', function (done) { request(server.url) diff --git a/server/tests/api/check-params/videos.js b/server/tests/api/check-params/videos.js index f8549a95b..0f5f40b8e 100644 --- a/server/tests/api/check-params/videos.js +++ b/server/tests/api/check-params/videos.js @@ -420,6 +420,48 @@ describe('Test videos API validator', function () { it('Should succeed with the correct parameters') }) + describe('When rating a video', function () { + let videoId + + before(function (done) { + videosUtils.getVideosList(server.url, function (err, res) { + if (err) throw err + + videoId = res.body.data[0].id + + return done() + }) + }) + + it('Should fail without a valid uuid', function (done) { + const data = { + rating: 'like' + } + requestsUtils.makePutBodyRequest(server.url, path + 'blabla/rate', server.accessToken, data, done) + }) + + it('Should fail with an unknown id', function (done) { + const data = { + rating: 'like' + } + requestsUtils.makePutBodyRequest(server.url, path + '4da6fde3-88f7-4d16-b119-108df5630b06/rate', server.accessToken, data, done, 404) + }) + + it('Should fail with a wrong rating', function (done) { + const data = { + rating: 'likes' + } + requestsUtils.makePutBodyRequest(server.url, path + videoId + '/rate', server.accessToken, data, done) + }) + + it('Should succeed with the correct parameters', function (done) { + const data = { + rating: 'like' + } + requestsUtils.makePutBodyRequest(server.url, path + videoId + '/rate', server.accessToken, data, done, 204) + }) + }) + describe('When removing a video', function () { it('Should have 404 with nothing', function (done) { request(server.url) diff --git a/server/tests/api/multiple-pods.js b/server/tests/api/multiple-pods.js index e02b6180b..552f10c6f 100644 --- a/server/tests/api/multiple-pods.js +++ b/server/tests/api/multiple-pods.js @@ -4,6 +4,7 @@ const chai = require('chai') const each = require('async/each') +const eachSeries = require('async/eachSeries') const expect = chai.expect const parallel = require('async/parallel') const series = require('async/series') @@ -378,7 +379,7 @@ describe('Test multiple pods', function () { }) }) - describe('Should update video views', function () { + describe('Should update video views, likes and dislikes', function () { let localVideosPod3 = [] let remoteVideosPod1 = [] let remoteVideosPod2 = [] @@ -419,7 +420,7 @@ describe('Test multiple pods', function () { ], done) }) - it('Should views multiple videos on owned servers', function (done) { + it('Should view multiple videos on owned servers', function (done) { this.timeout(30000) parallel([ @@ -440,18 +441,18 @@ describe('Test multiple pods', function () { }, function (callback) { - setTimeout(done, 22000) + setTimeout(callback, 22000) } ], function (err) { if (err) throw err - each(servers, function (server, callback) { + eachSeries(servers, function (server, callback) { videosUtils.getVideosList(server.url, function (err, res) { if (err) throw err const videos = res.body.data - expect(videos.find(video => video.views === 3)).to.be.exist - expect(videos.find(video => video.views === 1)).to.be.exist + expect(videos.find(video => video.views === 3)).to.exist + expect(videos.find(video => video.views === 1)).to.exist callback() }) @@ -459,7 +460,7 @@ describe('Test multiple pods', function () { }) }) - it('Should views multiple videos on each servers', function (done) { + it('Should view multiple videos on each servers', function (done) { this.timeout(30000) parallel([ @@ -504,17 +505,17 @@ describe('Test multiple pods', function () { }, function (callback) { - setTimeout(done, 22000) + setTimeout(callback, 22000) } ], function (err) { if (err) throw err let baseVideos = null - each(servers, function (server, callback) { + eachSeries(servers, function (server, callback) { videosUtils.getVideosList(server.url, function (err, res) { if (err) throw err - const videos = res.body + const videos = res.body.data // Initialize base videos for future comparisons if (baseVideos === null) { @@ -522,10 +523,74 @@ describe('Test multiple pods', function () { return callback() } - for (let i = 0; i < baseVideos.length; i++) { - expect(baseVideos[i].views).to.equal(videos[i].views) + baseVideos.forEach(baseVideo => { + const sameVideo = videos.find(video => video.name === baseVideo.name) + expect(baseVideo.views).to.equal(sameVideo.views) + }) + + callback() + }) + }, done) + }) + }) + + it('Should like and dislikes videos on different services', function (done) { + this.timeout(30000) + + parallel([ + function (callback) { + videosUtils.rateVideo(servers[0].url, servers[0].accessToken, remoteVideosPod1[0], 'like', callback) + }, + + function (callback) { + videosUtils.rateVideo(servers[0].url, servers[0].accessToken, remoteVideosPod1[0], 'dislike', callback) + }, + + function (callback) { + videosUtils.rateVideo(servers[0].url, servers[0].accessToken, remoteVideosPod1[0], 'like', callback) + }, + + function (callback) { + videosUtils.rateVideo(servers[2].url, servers[2].accessToken, localVideosPod3[1], 'like', callback) + }, + + function (callback) { + videosUtils.rateVideo(servers[2].url, servers[2].accessToken, localVideosPod3[1], 'dislike', callback) + }, + + function (callback) { + videosUtils.rateVideo(servers[2].url, servers[2].accessToken, remoteVideosPod3[1], 'dislike', callback) + }, + + function (callback) { + videosUtils.rateVideo(servers[2].url, servers[2].accessToken, remoteVideosPod3[0], 'like', callback) + }, + + function (callback) { + setTimeout(callback, 22000) + } + ], function (err) { + if (err) throw err + + let baseVideos = null + eachSeries(servers, function (server, callback) { + videosUtils.getVideosList(server.url, function (err, res) { + if (err) throw err + + const videos = res.body.data + + // Initialize base videos for future comparisons + if (baseVideos === null) { + baseVideos = videos + return callback() } + baseVideos.forEach(baseVideo => { + const sameVideo = videos.find(video => video.name === baseVideo.name) + expect(baseVideo.likes).to.equal(sameVideo.likes) + expect(baseVideo.dislikes).to.equal(sameVideo.dislikes) + }) + callback() }) }, done) diff --git a/server/tests/api/single-pod.js b/server/tests/api/single-pod.js index 87d0e9a71..96e4aff9e 100644 --- a/server/tests/api/single-pod.js +++ b/server/tests/api/single-pod.js @@ -609,6 +609,40 @@ describe('Test a single pod', function () { }) }) + it('Should like a video', function (done) { + videosUtils.rateVideo(server.url, server.accessToken, videoId, 'like', function (err) { + if (err) throw err + + videosUtils.getVideo(server.url, videoId, function (err, res) { + if (err) throw err + + const video = res.body + + expect(video.likes).to.equal(1) + expect(video.dislikes).to.equal(0) + + done() + }) + }) + }) + + it('Should dislike the same video', function (done) { + videosUtils.rateVideo(server.url, server.accessToken, videoId, 'dislike', function (err) { + if (err) throw err + + videosUtils.getVideo(server.url, videoId, function (err, res) { + if (err) throw err + + const video = res.body + + expect(video.likes).to.equal(0) + expect(video.dislikes).to.equal(1) + + done() + }) + }) + }) + after(function (done) { process.kill(-server.app.pid) diff --git a/server/tests/api/users.js b/server/tests/api/users.js index bd95e78c2..f9568b874 100644 --- a/server/tests/api/users.js +++ b/server/tests/api/users.js @@ -10,6 +10,7 @@ const loginUtils = require('../utils/login') const podsUtils = require('../utils/pods') const serversUtils = require('../utils/servers') const usersUtils = require('../utils/users') +const requestsUtils = require('../utils/requests') const videosUtils = require('../utils/videos') describe('Test users', function () { @@ -138,6 +139,23 @@ describe('Test users', function () { videosUtils.uploadVideo(server.url, accessToken, name, description, tags, video, 204, done) }) + it('Should retrieve a video rating', function (done) { + videosUtils.rateVideo(server.url, accessToken, videoId, 'like', function (err) { + if (err) throw err + + usersUtils.getUserVideoRating(server.url, accessToken, videoId, function (err, res) { + if (err) throw err + + const rating = res.body + + expect(rating.videoId).to.equal(videoId) + expect(rating.rating).to.equal('like') + + done() + }) + }) + }) + it('Should not be able to remove the video with an incorrect token', function (done) { videosUtils.removeVideo(server.url, 'bad_token', videoId, 401, done) }) @@ -150,10 +168,21 @@ describe('Test users', function () { it('Should logout (revoke token)') + it('Should not be able to get the user informations') + it('Should not be able to upload a video') it('Should not be able to remove a video') + it('Should not be able to rate a video', function (done) { + const path = '/api/v1/videos/' + const data = { + rating: 'likes' + } + + requestsUtils.makePutBodyRequest(server.url, path + videoId, 'wrong token', data, done, 401) + }) + it('Should be able to login again') it('Should have an expired access token') diff --git a/server/tests/utils/users.js b/server/tests/utils/users.js index a2c010f64..7817160b9 100644 --- a/server/tests/utils/users.js +++ b/server/tests/utils/users.js @@ -5,6 +5,7 @@ const request = require('supertest') const usersUtils = { createUser, getUserInformation, + getUserVideoRating, getUsersList, getUsersListPaginationAndSort, removeUser, @@ -47,6 +48,18 @@ function getUserInformation (url, accessToken, end) { .end(end) } +function getUserVideoRating (url, accessToken, videoId, end) { + const path = '/api/v1/users/me/videos/' + videoId + '/rating' + + request(url) + .get(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + accessToken) + .expect(200) + .expect('Content-Type', /json/) + .end(end) +} + function getUsersList (url, end) { const path = '/api/v1/users' diff --git a/server/tests/utils/videos.js b/server/tests/utils/videos.js index f94368437..177426076 100644 --- a/server/tests/utils/videos.js +++ b/server/tests/utils/videos.js @@ -16,7 +16,8 @@ const videosUtils = { searchVideoWithSort, testVideoImage, uploadVideo, - updateVideo + updateVideo, + rateVideo } // ---------------------- Export functions -------------------- @@ -236,6 +237,23 @@ function updateVideo (url, accessToken, id, name, description, tags, specialStat req.expect(specialStatus).end(end) } +function rateVideo (url, accessToken, id, rating, specialStatus, end) { + if (!end) { + end = specialStatus + specialStatus = 204 + } + + const path = '/api/v1/videos/' + id + '/rate' + + request(url) + .put(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + accessToken) + .send({ rating }) + .expect(specialStatus) + .end(end) +} + // --------------------------------------------------------------------------- module.exports = videosUtils -- 2.41.0