Add like/dislike system for videos

This commit is contained in:
Chocobozzz 2017-03-08 21:35:43 +01:00
parent 8f90644321
commit d38b828106
31 changed files with 907 additions and 47 deletions

View File

@ -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,

View File

@ -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';

View File

@ -0,0 +1 @@
export type RateType = 'like' | 'dislike';

View File

@ -1,3 +1,3 @@
export type SortField = "name" | "-name"
| "duration" | "-duration"
| "createdAt" | "-createdAt";
export type SortField = 'name' | '-name'
| 'duration' | '-duration'
| 'createdAt' | '-createdAt';

View File

@ -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);
}

View File

@ -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) {

View File

@ -32,7 +32,7 @@
<div *ngIf="video !== null" id="video-info">
<div class="row" id="video-name-actions">
<div class="col-md-8">
<div class="col-md-6">
<div class="row">
<div id="video-name" class="col-md-12">
{{ video.name }}
@ -52,7 +52,23 @@
</div>
</div>
<div id="video-actions" class="col-md-4 text-right">
<div id="video-actions" class="col-md-6 text-right">
<div id="rates">
<button
id="likes" class="btn btn-default"
[ngClass]="{ 'not-interactive-btn': !isUserLoggedIn(), 'activated-btn': userRating === 'like' }" (click)="setLike()"
>
<span class="glyphicon glyphicon-thumbs-up"></span> {{ video.likes }}
</button>
<button
id="dislikes" class="btn btn-default"
[ngClass]="{ 'not-interactive-btn': !isUserLoggedIn(), 'activated-btn': userRating === 'dislike' }" (click)="setDislike()"
>
<span class=" glyphicon glyphicon-thumbs-down"></span> {{ video.dislikes }}
</button>
</div>
<button id="share" class="btn btn-default" (click)="showShareModal()">
<span class="glyphicon glyphicon-share"></span> Share
</button>

View File

@ -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;

View File

@ -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.');

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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
}
// ---------------------------------------------------------------------------

View File

@ -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.')
}

View File

@ -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.')
}

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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')

View File

@ -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'

View File

@ -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