From 65fcc3119c334b75dd13bcfdebf186afdc580a8f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 15 May 2017 22:22:03 +0200 Subject: First typescript iteration --- server/models/application.js | 52 --- server/models/application.ts | 50 ++ server/models/author.js | 92 ---- server/models/author.ts | 90 ++++ server/models/job.js | 54 --- server/models/job.ts | 52 +++ server/models/oauth-client.js | 62 --- server/models/oauth-client.ts | 60 +++ server/models/oauth-token.js | 144 ------ server/models/oauth-token.ts | 142 ++++++ server/models/pod.js | 273 ----------- server/models/pod.ts | 269 +++++++++++ server/models/request-to-pod.js | 42 -- server/models/request-to-pod.ts | 38 ++ server/models/request-video-event.js | 172 ------- server/models/request-video-event.ts | 170 +++++++ server/models/request-video-qadu.js | 151 ------ server/models/request-video-qadu.ts | 149 ++++++ server/models/request.js | 137 ------ server/models/request.ts | 135 ++++++ server/models/tag.js | 76 --- server/models/tag.ts | 74 +++ server/models/user-video-rate.js | 77 --- server/models/user-video-rate.ts | 74 +++ server/models/user.js | 194 -------- server/models/user.ts | 197 ++++++++ server/models/utils.js | 25 - server/models/utils.ts | 21 + server/models/video-abuse.js | 114 ----- server/models/video-abuse.ts | 112 +++++ server/models/video-blacklist.js | 89 ---- server/models/video-blacklist.ts | 87 ++++ server/models/video-tag.js | 18 - server/models/video-tag.ts | 14 + server/models/video.js | 858 ---------------------------------- server/models/video.ts | 873 +++++++++++++++++++++++++++++++++++ 36 files changed, 2607 insertions(+), 2630 deletions(-) delete mode 100644 server/models/application.js create mode 100644 server/models/application.ts delete mode 100644 server/models/author.js create mode 100644 server/models/author.ts delete mode 100644 server/models/job.js create mode 100644 server/models/job.ts delete mode 100644 server/models/oauth-client.js create mode 100644 server/models/oauth-client.ts delete mode 100644 server/models/oauth-token.js create mode 100644 server/models/oauth-token.ts delete mode 100644 server/models/pod.js create mode 100644 server/models/pod.ts delete mode 100644 server/models/request-to-pod.js create mode 100644 server/models/request-to-pod.ts delete mode 100644 server/models/request-video-event.js create mode 100644 server/models/request-video-event.ts delete mode 100644 server/models/request-video-qadu.js create mode 100644 server/models/request-video-qadu.ts delete mode 100644 server/models/request.js create mode 100644 server/models/request.ts delete mode 100644 server/models/tag.js create mode 100644 server/models/tag.ts delete mode 100644 server/models/user-video-rate.js create mode 100644 server/models/user-video-rate.ts delete mode 100644 server/models/user.js create mode 100644 server/models/user.ts delete mode 100644 server/models/utils.js create mode 100644 server/models/utils.ts delete mode 100644 server/models/video-abuse.js create mode 100644 server/models/video-abuse.ts delete mode 100644 server/models/video-blacklist.js create mode 100644 server/models/video-blacklist.ts delete mode 100644 server/models/video-tag.js create mode 100644 server/models/video-tag.ts delete mode 100644 server/models/video.js create mode 100644 server/models/video.ts (limited to 'server/models') diff --git a/server/models/application.js b/server/models/application.js deleted file mode 100644 index 64e1a0540..000000000 --- a/server/models/application.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict' - -module.exports = function (sequelize, DataTypes) { - const Application = sequelize.define('Application', - { - migrationVersion: { - type: DataTypes.INTEGER, - defaultValue: 0, - allowNull: false, - validate: { - isInt: true - } - } - }, - { - classMethods: { - loadMigrationVersion, - updateMigrationVersion - } - } - ) - - return Application -} - -// --------------------------------------------------------------------------- - -function loadMigrationVersion (callback) { - const query = { - attributes: [ 'migrationVersion' ] - } - - return this.findOne(query).asCallback(function (err, data) { - const version = data ? data.migrationVersion : null - - return callback(err, version) - }) -} - -function updateMigrationVersion (newVersion, transaction, callback) { - const options = { - where: {} - } - - if (!callback) { - transaction = callback - } else { - options.transaction = transaction - } - - return this.update({ migrationVersion: newVersion }, options).asCallback(callback) -} diff --git a/server/models/application.ts b/server/models/application.ts new file mode 100644 index 000000000..38a57e327 --- /dev/null +++ b/server/models/application.ts @@ -0,0 +1,50 @@ +module.exports = function (sequelize, DataTypes) { + const Application = sequelize.define('Application', + { + migrationVersion: { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false, + validate: { + isInt: true + } + } + }, + { + classMethods: { + loadMigrationVersion, + updateMigrationVersion + } + } + ) + + return Application +} + +// --------------------------------------------------------------------------- + +function loadMigrationVersion (callback) { + const query = { + attributes: [ 'migrationVersion' ] + } + + return this.findOne(query).asCallback(function (err, data) { + const version = data ? data.migrationVersion : null + + return callback(err, version) + }) +} + +function updateMigrationVersion (newVersion, transaction, callback) { + const options: { where?: any, transaction?: any } = { + where: {} + } + + if (!callback) { + transaction = callback + } else { + options.transaction = transaction + } + + return this.update({ migrationVersion: newVersion }, options).asCallback(callback) +} diff --git a/server/models/author.js b/server/models/author.js deleted file mode 100644 index 34b013097..000000000 --- a/server/models/author.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict' - -const customUsersValidators = require('../helpers/custom-validators').users - -module.exports = function (sequelize, DataTypes) { - const Author = sequelize.define('Author', - { - name: { - type: DataTypes.STRING, - allowNull: false, - validate: { - usernameValid: function (value) { - const res = customUsersValidators.isUserUsernameValid(value) - if (res === false) throw new Error('Username is not valid.') - } - } - } - }, - { - indexes: [ - { - fields: [ 'name' ] - }, - { - fields: [ 'podId' ] - }, - { - fields: [ 'userId' ], - unique: true - }, - { - fields: [ 'name', 'podId' ], - unique: true - } - ], - classMethods: { - associate, - - findOrCreateAuthor - } - } - ) - - return Author -} - -// --------------------------------------------------------------------------- - -function associate (models) { - this.belongsTo(models.Pod, { - foreignKey: { - name: 'podId', - allowNull: true - }, - onDelete: 'cascade' - }) - - this.belongsTo(models.User, { - foreignKey: { - name: 'userId', - allowNull: true - }, - onDelete: 'cascade' - }) -} - -function findOrCreateAuthor (name, podId, userId, transaction, callback) { - if (!callback) { - callback = transaction - transaction = null - } - - const author = { - name, - podId, - userId - } - - const query = { - where: author, - defaults: author - } - - if (transaction) query.transaction = transaction - - this.findOrCreate(query).asCallback(function (err, result) { - if (err) return callback(err) - - // [ instance, wasCreated ] - return callback(null, result[0]) - }) -} diff --git a/server/models/author.ts b/server/models/author.ts new file mode 100644 index 000000000..4a7396929 --- /dev/null +++ b/server/models/author.ts @@ -0,0 +1,90 @@ +import { isUserUsernameValid } from '../helpers' + +module.exports = function (sequelize, DataTypes) { + const Author = sequelize.define('Author', + { + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + usernameValid: function (value) { + const res = isUserUsernameValid(value) + if (res === false) throw new Error('Username is not valid.') + } + } + } + }, + { + indexes: [ + { + fields: [ 'name' ] + }, + { + fields: [ 'podId' ] + }, + { + fields: [ 'userId' ], + unique: true + }, + { + fields: [ 'name', 'podId' ], + unique: true + } + ], + classMethods: { + associate, + + findOrCreateAuthor + } + } + ) + + return Author +} + +// --------------------------------------------------------------------------- + +function associate (models) { + this.belongsTo(models.Pod, { + foreignKey: { + name: 'podId', + allowNull: true + }, + onDelete: 'cascade' + }) + + this.belongsTo(models.User, { + foreignKey: { + name: 'userId', + allowNull: true + }, + onDelete: 'cascade' + }) +} + +function findOrCreateAuthor (name, podId, userId, transaction, callback) { + if (!callback) { + callback = transaction + transaction = null + } + + const author = { + name, + podId, + userId + } + + const query: any = { + where: author, + defaults: author + } + + if (transaction) query.transaction = transaction + + this.findOrCreate(query).asCallback(function (err, result) { + if (err) return callback(err) + + // [ instance, wasCreated ] + return callback(null, result[0]) + }) +} diff --git a/server/models/job.js b/server/models/job.js deleted file mode 100644 index 949f88d44..000000000 --- a/server/models/job.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict' - -const values = require('lodash/values') - -const constants = require('../initializers/constants') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const Job = sequelize.define('Job', - { - state: { - type: DataTypes.ENUM(values(constants.JOB_STATES)), - allowNull: false - }, - handlerName: { - type: DataTypes.STRING, - allowNull: false - }, - handlerInputData: { - type: DataTypes.JSON, - allowNull: true - } - }, - { - indexes: [ - { - fields: [ 'state' ] - } - ], - classMethods: { - listWithLimit - } - } - ) - - return Job -} - -// --------------------------------------------------------------------------- - -function listWithLimit (limit, state, callback) { - const query = { - order: [ - [ 'id', 'ASC' ] - ], - limit: limit, - where: { - state - } - } - - return this.findAll(query).asCallback(callback) -} diff --git a/server/models/job.ts b/server/models/job.ts new file mode 100644 index 000000000..6843e399b --- /dev/null +++ b/server/models/job.ts @@ -0,0 +1,52 @@ +import { values } from 'lodash' + +import { JOB_STATES } from '../initializers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const Job = sequelize.define('Job', + { + state: { + type: DataTypes.ENUM(values(JOB_STATES)), + allowNull: false + }, + handlerName: { + type: DataTypes.STRING, + allowNull: false + }, + handlerInputData: { + type: DataTypes.JSON, + allowNull: true + } + }, + { + indexes: [ + { + fields: [ 'state' ] + } + ], + classMethods: { + listWithLimit + } + } + ) + + return Job +} + +// --------------------------------------------------------------------------- + +function listWithLimit (limit, state, callback) { + const query = { + order: [ + [ 'id', 'ASC' ] + ], + limit: limit, + where: { + state + } + } + + return this.findAll(query).asCallback(callback) +} diff --git a/server/models/oauth-client.js b/server/models/oauth-client.js deleted file mode 100644 index 021a34007..000000000 --- a/server/models/oauth-client.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict' - -module.exports = function (sequelize, DataTypes) { - const OAuthClient = sequelize.define('OAuthClient', - { - clientId: { - type: DataTypes.STRING, - allowNull: false - }, - clientSecret: { - type: DataTypes.STRING, - allowNull: false - }, - grants: { - type: DataTypes.ARRAY(DataTypes.STRING) - }, - redirectUris: { - type: DataTypes.ARRAY(DataTypes.STRING) - } - }, - { - indexes: [ - { - fields: [ 'clientId' ], - unique: true - }, - { - fields: [ 'clientId', 'clientSecret' ], - unique: true - } - ], - classMethods: { - countTotal, - getByIdAndSecret, - loadFirstClient - } - } - ) - - return OAuthClient -} - -// --------------------------------------------------------------------------- - -function countTotal (callback) { - return this.count().asCallback(callback) -} - -function loadFirstClient (callback) { - return this.findOne().asCallback(callback) -} - -function getByIdAndSecret (clientId, clientSecret) { - const query = { - where: { - clientId: clientId, - clientSecret: clientSecret - } - } - - return this.findOne(query) -} diff --git a/server/models/oauth-client.ts b/server/models/oauth-client.ts new file mode 100644 index 000000000..3198a85ef --- /dev/null +++ b/server/models/oauth-client.ts @@ -0,0 +1,60 @@ +module.exports = function (sequelize, DataTypes) { + const OAuthClient = sequelize.define('OAuthClient', + { + clientId: { + type: DataTypes.STRING, + allowNull: false + }, + clientSecret: { + type: DataTypes.STRING, + allowNull: false + }, + grants: { + type: DataTypes.ARRAY(DataTypes.STRING) + }, + redirectUris: { + type: DataTypes.ARRAY(DataTypes.STRING) + } + }, + { + indexes: [ + { + fields: [ 'clientId' ], + unique: true + }, + { + fields: [ 'clientId', 'clientSecret' ], + unique: true + } + ], + classMethods: { + countTotal, + getByIdAndSecret, + loadFirstClient + } + } + ) + + return OAuthClient +} + +// --------------------------------------------------------------------------- + +function countTotal (callback) { + return this.count().asCallback(callback) +} + +function loadFirstClient (callback) { + return this.findOne().asCallback(callback) +} + +function getByIdAndSecret (clientId, clientSecret) { + const query = { + where: { + clientId: clientId, + clientSecret: clientSecret + } + } + + return this.findOne(query) +} diff --git a/server/models/oauth-token.js b/server/models/oauth-token.js deleted file mode 100644 index 68e7c9ff7..000000000 --- a/server/models/oauth-token.js +++ /dev/null @@ -1,144 +0,0 @@ -'use strict' - -const logger = require('../helpers/logger') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const OAuthToken = sequelize.define('OAuthToken', - { - accessToken: { - type: DataTypes.STRING, - allowNull: false - }, - accessTokenExpiresAt: { - type: DataTypes.DATE, - allowNull: false - }, - refreshToken: { - type: DataTypes.STRING, - allowNull: false - }, - refreshTokenExpiresAt: { - type: DataTypes.DATE, - allowNull: false - } - }, - { - indexes: [ - { - fields: [ 'refreshToken' ], - unique: true - }, - { - fields: [ 'accessToken' ], - unique: true - }, - { - fields: [ 'userId' ] - }, - { - fields: [ 'oAuthClientId' ] - } - ], - classMethods: { - associate, - - getByRefreshTokenAndPopulateClient, - getByTokenAndPopulateUser, - getByRefreshTokenAndPopulateUser, - removeByUserId - } - } - ) - - return OAuthToken -} - -// --------------------------------------------------------------------------- - -function associate (models) { - this.belongsTo(models.User, { - foreignKey: { - name: 'userId', - allowNull: false - }, - onDelete: 'cascade' - }) - - this.belongsTo(models.OAuthClient, { - foreignKey: { - name: 'oAuthClientId', - allowNull: false - }, - onDelete: 'cascade' - }) -} - -function getByRefreshTokenAndPopulateClient (refreshToken) { - const query = { - where: { - refreshToken: refreshToken - }, - include: [ this.associations.OAuthClient ] - } - - return this.findOne(query).then(function (token) { - if (!token) return token - - const tokenInfos = { - refreshToken: token.refreshToken, - refreshTokenExpiresAt: token.refreshTokenExpiresAt, - client: { - id: token.client.id - }, - user: { - id: token.user - } - } - - return tokenInfos - }).catch(function (err) { - logger.info('getRefreshToken error.', { error: err }) - }) -} - -function getByTokenAndPopulateUser (bearerToken) { - const query = { - where: { - accessToken: bearerToken - }, - include: [ this.sequelize.models.User ] - } - - return this.findOne(query).then(function (token) { - if (token) token.user = token.User - - return token - }) -} - -function getByRefreshTokenAndPopulateUser (refreshToken) { - const query = { - where: { - refreshToken: refreshToken - }, - include: [ this.sequelize.models.User ] - } - - return this.findOne(query).then(function (token) { - token.user = token.User - - return token - }) -} - -function removeByUserId (userId, callback) { - const query = { - where: { - userId: userId - } - } - - return this.destroy(query).asCallback(callback) -} diff --git a/server/models/oauth-token.ts b/server/models/oauth-token.ts new file mode 100644 index 000000000..74c9180eb --- /dev/null +++ b/server/models/oauth-token.ts @@ -0,0 +1,142 @@ +import { logger } from '../helpers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const OAuthToken = sequelize.define('OAuthToken', + { + accessToken: { + type: DataTypes.STRING, + allowNull: false + }, + accessTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + refreshToken: { + type: DataTypes.STRING, + allowNull: false + }, + refreshTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { + indexes: [ + { + fields: [ 'refreshToken' ], + unique: true + }, + { + fields: [ 'accessToken' ], + unique: true + }, + { + fields: [ 'userId' ] + }, + { + fields: [ 'oAuthClientId' ] + } + ], + classMethods: { + associate, + + getByRefreshTokenAndPopulateClient, + getByTokenAndPopulateUser, + getByRefreshTokenAndPopulateUser, + removeByUserId + } + } + ) + + return OAuthToken +} + +// --------------------------------------------------------------------------- + +function associate (models) { + this.belongsTo(models.User, { + foreignKey: { + name: 'userId', + allowNull: false + }, + onDelete: 'cascade' + }) + + this.belongsTo(models.OAuthClient, { + foreignKey: { + name: 'oAuthClientId', + allowNull: false + }, + onDelete: 'cascade' + }) +} + +function getByRefreshTokenAndPopulateClient (refreshToken) { + const query = { + where: { + refreshToken: refreshToken + }, + include: [ this.associations.OAuthClient ] + } + + return this.findOne(query).then(function (token) { + if (!token) return token + + const tokenInfos = { + refreshToken: token.refreshToken, + refreshTokenExpiresAt: token.refreshTokenExpiresAt, + client: { + id: token.client.id + }, + user: { + id: token.user + } + } + + return tokenInfos + }).catch(function (err) { + logger.info('getRefreshToken error.', { error: err }) + }) +} + +function getByTokenAndPopulateUser (bearerToken) { + const query = { + where: { + accessToken: bearerToken + }, + include: [ this.sequelize.models.User ] + } + + return this.findOne(query).then(function (token) { + if (token) token.user = token.User + + return token + }) +} + +function getByRefreshTokenAndPopulateUser (refreshToken) { + const query = { + where: { + refreshToken: refreshToken + }, + include: [ this.sequelize.models.User ] + } + + return this.findOne(query).then(function (token) { + token.user = token.User + + return token + }) +} + +function removeByUserId (userId, callback) { + const query = { + where: { + userId: userId + } + } + + return this.destroy(query).asCallback(callback) +} diff --git a/server/models/pod.js b/server/models/pod.js deleted file mode 100644 index 8e2d488e1..000000000 --- a/server/models/pod.js +++ /dev/null @@ -1,273 +0,0 @@ -'use strict' - -const each = require('async/each') -const map = require('lodash/map') -const waterfall = require('async/waterfall') - -const constants = require('../initializers/constants') -const logger = require('../helpers/logger') -const customPodsValidators = require('../helpers/custom-validators').pods - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const Pod = sequelize.define('Pod', - { - host: { - type: DataTypes.STRING, - allowNull: false, - validate: { - isHost: function (value) { - const res = customPodsValidators.isHostValid(value) - if (res === false) throw new Error('Host not valid.') - } - } - }, - publicKey: { - type: DataTypes.STRING(5000), - allowNull: false - }, - score: { - type: DataTypes.INTEGER, - defaultValue: constants.FRIEND_SCORE.BASE, - allowNull: false, - validate: { - isInt: true, - max: constants.FRIEND_SCORE.MAX - } - }, - email: { - type: DataTypes.STRING(400), - allowNull: false, - validate: { - isEmail: true - } - } - }, - { - indexes: [ - { - fields: [ 'host' ], - unique: true - }, - { - fields: [ 'score' ] - } - ], - classMethods: { - associate, - - countAll, - incrementScores, - list, - listAllIds, - listRandomPodIdsWithRequest, - listBadPods, - load, - loadByHost, - updatePodsScore, - removeAll - }, - instanceMethods: { - toFormatedJSON - } - } - ) - - return Pod -} - -// ------------------------------ METHODS ------------------------------ - -function toFormatedJSON () { - const json = { - id: this.id, - host: this.host, - email: this.email, - score: this.score, - createdAt: this.createdAt - } - - return json -} - -// ------------------------------ Statics ------------------------------ - -function associate (models) { - this.belongsToMany(models.Request, { - foreignKey: 'podId', - through: models.RequestToPod, - onDelete: 'cascade' - }) -} - -function countAll (callback) { - return this.count().asCallback(callback) -} - -function incrementScores (ids, value, callback) { - if (!callback) callback = function () {} - - const update = { - score: this.sequelize.literal('score +' + value) - } - - const options = { - where: { - id: { - $in: ids - } - }, - // In this case score is a literal and not an integer so we do not validate it - validate: false - } - - return this.update(update, options).asCallback(callback) -} - -function list (callback) { - return this.findAll().asCallback(callback) -} - -function listAllIds (transaction, callback) { - if (!callback) { - callback = transaction - transaction = null - } - - const query = { - attributes: [ 'id' ] - } - - if (transaction) query.transaction = transaction - - return this.findAll(query).asCallback(function (err, pods) { - if (err) return callback(err) - - return callback(null, map(pods, 'id')) - }) -} - -function listRandomPodIdsWithRequest (limit, tableWithPods, tableWithPodsJoins, callback) { - if (!callback) { - callback = tableWithPodsJoins - tableWithPodsJoins = '' - } - - const self = this - - self.count().asCallback(function (err, count) { - if (err) return callback(err) - - // Optimization... - if (count === 0) return callback(null, []) - - let start = Math.floor(Math.random() * count) - limit - if (start < 0) start = 0 - - const query = { - attributes: [ 'id' ], - order: [ - [ 'id', 'ASC' ] - ], - offset: start, - limit: limit, - where: { - id: { - $in: [ - this.sequelize.literal(`SELECT DISTINCT "${tableWithPods}"."podId" FROM "${tableWithPods}" ${tableWithPodsJoins}`) - ] - } - } - } - - return this.findAll(query).asCallback(function (err, pods) { - if (err) return callback(err) - - return callback(null, map(pods, 'id')) - }) - }) -} - -function listBadPods (callback) { - const query = { - where: { - score: { $lte: 0 } - } - } - - return this.findAll(query).asCallback(callback) -} - -function load (id, callback) { - return this.findById(id).asCallback(callback) -} - -function loadByHost (host, callback) { - const query = { - where: { - host: host - } - } - - return this.findOne(query).asCallback(callback) -} - -function removeAll (callback) { - return this.destroy().asCallback(callback) -} - -function updatePodsScore (goodPods, badPods) { - const self = this - - logger.info('Updating %d good pods and %d bad pods scores.', goodPods.length, badPods.length) - - if (goodPods.length !== 0) { - this.incrementScores(goodPods, constants.PODS_SCORE.BONUS, function (err) { - if (err) logger.error('Cannot increment scores of good pods.', { error: err }) - }) - } - - if (badPods.length !== 0) { - this.incrementScores(badPods, constants.PODS_SCORE.MALUS, function (err) { - if (err) logger.error('Cannot decrement scores of bad pods.', { error: err }) - removeBadPods.call(self) - }) - } -} - -// --------------------------------------------------------------------------- - -// Remove pods with a score of 0 (too many requests where they were unreachable) -function removeBadPods () { - const self = this - - waterfall([ - function findBadPods (callback) { - self.sequelize.models.Pod.listBadPods(function (err, pods) { - if (err) { - logger.error('Cannot find bad pods.', { error: err }) - return callback(err) - } - - return callback(null, pods) - }) - }, - - function removeTheseBadPods (pods, callback) { - each(pods, function (pod, callbackEach) { - pod.destroy().asCallback(callbackEach) - }, function (err) { - return callback(err, pods.length) - }) - } - ], function (err, numberOfPodsRemoved) { - if (err) { - logger.error('Cannot remove bad pods.', { error: err }) - } else if (numberOfPodsRemoved) { - logger.info('Removed %d pods.', numberOfPodsRemoved) - } else { - logger.info('No need to remove bad pods.') - } - }) -} diff --git a/server/models/pod.ts b/server/models/pod.ts new file mode 100644 index 000000000..0e0262978 --- /dev/null +++ b/server/models/pod.ts @@ -0,0 +1,269 @@ +import { each, waterfall } from 'async' +import { map } from 'lodash' + +import { FRIEND_SCORE, PODS_SCORE } from '../initializers' +import { logger, isHostValid } from '../helpers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const Pod = sequelize.define('Pod', + { + host: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isHost: function (value) { + const res = isHostValid(value) + if (res === false) throw new Error('Host not valid.') + } + } + }, + publicKey: { + type: DataTypes.STRING(5000), + allowNull: false + }, + score: { + type: DataTypes.INTEGER, + defaultValue: FRIEND_SCORE.BASE, + allowNull: false, + validate: { + isInt: true, + max: FRIEND_SCORE.MAX + } + }, + email: { + type: DataTypes.STRING(400), + allowNull: false, + validate: { + isEmail: true + } + } + }, + { + indexes: [ + { + fields: [ 'host' ], + unique: true + }, + { + fields: [ 'score' ] + } + ], + classMethods: { + associate, + + countAll, + incrementScores, + list, + listAllIds, + listRandomPodIdsWithRequest, + listBadPods, + load, + loadByHost, + updatePodsScore, + removeAll + }, + instanceMethods: { + toFormatedJSON + } + } + ) + + return Pod +} + +// ------------------------------ METHODS ------------------------------ + +function toFormatedJSON () { + const json = { + id: this.id, + host: this.host, + email: this.email, + score: this.score, + createdAt: this.createdAt + } + + return json +} + +// ------------------------------ Statics ------------------------------ + +function associate (models) { + this.belongsToMany(models.Request, { + foreignKey: 'podId', + through: models.RequestToPod, + onDelete: 'cascade' + }) +} + +function countAll (callback) { + return this.count().asCallback(callback) +} + +function incrementScores (ids, value, callback) { + if (!callback) callback = function () { /* empty */ } + + const update = { + score: this.sequelize.literal('score +' + value) + } + + const options = { + where: { + id: { + $in: ids + } + }, + // In this case score is a literal and not an integer so we do not validate it + validate: false + } + + return this.update(update, options).asCallback(callback) +} + +function list (callback) { + return this.findAll().asCallback(callback) +} + +function listAllIds (transaction, callback) { + if (!callback) { + callback = transaction + transaction = null + } + + const query: any = { + attributes: [ 'id' ] + } + + if (transaction) query.transaction = transaction + + return this.findAll(query).asCallback(function (err, pods) { + if (err) return callback(err) + + return callback(null, map(pods, 'id')) + }) +} + +function listRandomPodIdsWithRequest (limit, tableWithPods, tableWithPodsJoins, callback) { + if (!callback) { + callback = tableWithPodsJoins + tableWithPodsJoins = '' + } + + const self = this + + self.count().asCallback(function (err, count) { + if (err) return callback(err) + + // Optimization... + if (count === 0) return callback(null, []) + + let start = Math.floor(Math.random() * count) - limit + if (start < 0) start = 0 + + const query = { + attributes: [ 'id' ], + order: [ + [ 'id', 'ASC' ] + ], + offset: start, + limit: limit, + where: { + id: { + $in: [ + this.sequelize.literal(`SELECT DISTINCT "${tableWithPods}"."podId" FROM "${tableWithPods}" ${tableWithPodsJoins}`) + ] + } + } + } + + return this.findAll(query).asCallback(function (err, pods) { + if (err) return callback(err) + + return callback(null, map(pods, 'id')) + }) + }) +} + +function listBadPods (callback) { + const query = { + where: { + score: { $lte: 0 } + } + } + + return this.findAll(query).asCallback(callback) +} + +function load (id, callback) { + return this.findById(id).asCallback(callback) +} + +function loadByHost (host, callback) { + const query = { + where: { + host: host + } + } + + return this.findOne(query).asCallback(callback) +} + +function removeAll (callback) { + return this.destroy().asCallback(callback) +} + +function updatePodsScore (goodPods, badPods) { + const self = this + + logger.info('Updating %d good pods and %d bad pods scores.', goodPods.length, badPods.length) + + if (goodPods.length !== 0) { + this.incrementScores(goodPods, PODS_SCORE.BONUS, function (err) { + if (err) logger.error('Cannot increment scores of good pods.', { error: err }) + }) + } + + if (badPods.length !== 0) { + this.incrementScores(badPods, PODS_SCORE.MALUS, function (err) { + if (err) logger.error('Cannot decrement scores of bad pods.', { error: err }) + removeBadPods.call(self) + }) + } +} + +// --------------------------------------------------------------------------- + +// Remove pods with a score of 0 (too many requests where they were unreachable) +function removeBadPods () { + const self = this + + waterfall([ + function findBadPods (callback) { + self.sequelize.models.Pod.listBadPods(function (err, pods) { + if (err) { + logger.error('Cannot find bad pods.', { error: err }) + return callback(err) + } + + return callback(null, pods) + }) + }, + + function removeTheseBadPods (pods, callback) { + each(pods, function (pod: any, callbackEach) { + pod.destroy().asCallback(callbackEach) + }, function (err) { + return callback(err, pods.length) + }) + } + ], function (err, numberOfPodsRemoved) { + if (err) { + logger.error('Cannot remove bad pods.', { error: err }) + } else if (numberOfPodsRemoved) { + logger.info('Removed %d pods.', numberOfPodsRemoved) + } else { + logger.info('No need to remove bad pods.') + } + }) +} diff --git a/server/models/request-to-pod.js b/server/models/request-to-pod.js deleted file mode 100644 index 0e01a842e..000000000 --- a/server/models/request-to-pod.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict' - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const RequestToPod = sequelize.define('RequestToPod', {}, { - indexes: [ - { - fields: [ 'requestId' ] - }, - { - fields: [ 'podId' ] - }, - { - fields: [ 'requestId', 'podId' ], - unique: true - } - ], - classMethods: { - removeByRequestIdsAndPod - } - }) - - return RequestToPod -} - -// --------------------------------------------------------------------------- - -function removeByRequestIdsAndPod (requestsIds, podId, callback) { - if (!callback) callback = function () {} - - const query = { - where: { - requestId: { - $in: requestsIds - }, - podId: podId - } - } - - this.destroy(query).asCallback(callback) -} diff --git a/server/models/request-to-pod.ts b/server/models/request-to-pod.ts new file mode 100644 index 000000000..479202e40 --- /dev/null +++ b/server/models/request-to-pod.ts @@ -0,0 +1,38 @@ +module.exports = function (sequelize, DataTypes) { + const RequestToPod = sequelize.define('RequestToPod', {}, { + indexes: [ + { + fields: [ 'requestId' ] + }, + { + fields: [ 'podId' ] + }, + { + fields: [ 'requestId', 'podId' ], + unique: true + } + ], + classMethods: { + removeByRequestIdsAndPod + } + }) + + return RequestToPod +} + +// --------------------------------------------------------------------------- + +function removeByRequestIdsAndPod (requestsIds, podId, callback) { + if (!callback) callback = function () { /* empty */ } + + const query = { + where: { + requestId: { + $in: requestsIds + }, + podId: podId + } + } + + this.destroy(query).asCallback(callback) +} diff --git a/server/models/request-video-event.js b/server/models/request-video-event.js deleted file mode 100644 index 9ebeaec90..000000000 --- a/server/models/request-video-event.js +++ /dev/null @@ -1,172 +0,0 @@ -'use strict' - -/* - Request Video events (likes, dislikes, views...) -*/ - -const values = require('lodash/values') - -const constants = require('../initializers/constants') -const customVideosValidators = require('../helpers/custom-validators').videos - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const RequestVideoEvent = sequelize.define('RequestVideoEvent', - { - type: { - type: DataTypes.ENUM(values(constants.REQUEST_VIDEO_EVENT_TYPES)), - allowNull: false - }, - count: { - type: DataTypes.INTEGER, - allowNull: false, - validate: { - countValid: function (value) { - const res = customVideosValidators.isVideoEventCountValid(value) - if (res === false) throw new Error('Video event count is not valid.') - } - } - } - }, - { - updatedAt: false, - indexes: [ - { - fields: [ 'videoId' ] - } - ], - classMethods: { - associate, - - listWithLimitAndRandom, - - countTotalRequests, - removeAll, - removeByRequestIdsAndPod - } - } - ) - - return RequestVideoEvent -} - -// ------------------------------ STATICS ------------------------------ - -function associate (models) { - this.belongsTo(models.Video, { - foreignKey: { - name: 'videoId', - allowNull: false - }, - onDelete: 'CASCADE' - }) -} - -function countTotalRequests (callback) { - const query = {} - return this.count(query).asCallback(callback) -} - -function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { - const self = this - const Pod = this.sequelize.models.Pod - - // We make a join between videos and authors to find the podId of our video event requests - const podJoins = 'INNER JOIN "Videos" ON "Videos"."authorId" = "Authors"."id" ' + - 'INNER JOIN "RequestVideoEvents" ON "RequestVideoEvents"."videoId" = "Videos"."id"' - - Pod.listRandomPodIdsWithRequest(limitPods, 'Authors', podJoins, function (err, podIds) { - if (err) return callback(err) - - // We don't have friends that have requests - if (podIds.length === 0) return callback(null, []) - - const query = { - order: [ - [ 'id', 'ASC' ] - ], - include: [ - { - model: self.sequelize.models.Video, - include: [ - { - model: self.sequelize.models.Author, - include: [ - { - model: self.sequelize.models.Pod, - where: { - id: { - $in: podIds - } - } - } - ] - } - ] - } - ] - } - - self.findAll(query).asCallback(function (err, requests) { - if (err) return callback(err) - - const requestsGrouped = groupAndTruncateRequests(requests, limitRequestsPerPod) - return callback(err, requestsGrouped) - }) - }) -} - -function removeByRequestIdsAndPod (ids, podId, callback) { - const query = { - where: { - id: { - $in: ids - } - }, - include: [ - { - model: this.sequelize.models.Video, - include: [ - { - model: this.sequelize.models.Author, - where: { - podId - } - } - ] - } - ] - } - - this.destroy(query).asCallback(callback) -} - -function removeAll (callback) { - // Delete all requests - this.truncate({ cascade: true }).asCallback(callback) -} - -// --------------------------------------------------------------------------- - -function groupAndTruncateRequests (events, limitRequestsPerPod) { - const eventsGrouped = {} - - events.forEach(function (event) { - const pod = event.Video.Author.Pod - - if (!eventsGrouped[pod.id]) eventsGrouped[pod.id] = [] - - if (eventsGrouped[pod.id].length < limitRequestsPerPod) { - eventsGrouped[pod.id].push({ - id: event.id, - type: event.type, - count: event.count, - video: event.Video, - pod - }) - } - }) - - return eventsGrouped -} diff --git a/server/models/request-video-event.ts b/server/models/request-video-event.ts new file mode 100644 index 000000000..c61525029 --- /dev/null +++ b/server/models/request-video-event.ts @@ -0,0 +1,170 @@ +/* + Request Video events (likes, dislikes, views...) +*/ + +import { values } from 'lodash' + +import { REQUEST_VIDEO_EVENT_TYPES } from '../initializers' +import { isVideoEventCountValid } from '../helpers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const RequestVideoEvent = sequelize.define('RequestVideoEvent', + { + type: { + type: DataTypes.ENUM(values(REQUEST_VIDEO_EVENT_TYPES)), + allowNull: false + }, + count: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + countValid: function (value) { + const res = isVideoEventCountValid(value) + if (res === false) throw new Error('Video event count is not valid.') + } + } + } + }, + { + updatedAt: false, + indexes: [ + { + fields: [ 'videoId' ] + } + ], + classMethods: { + associate, + + listWithLimitAndRandom, + + countTotalRequests, + removeAll, + removeByRequestIdsAndPod + } + } + ) + + return RequestVideoEvent +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + this.belongsTo(models.Video, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'CASCADE' + }) +} + +function countTotalRequests (callback) { + const query = {} + return this.count(query).asCallback(callback) +} + +function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { + const self = this + const Pod = this.sequelize.models.Pod + + // We make a join between videos and authors to find the podId of our video event requests + const podJoins = 'INNER JOIN "Videos" ON "Videos"."authorId" = "Authors"."id" ' + + 'INNER JOIN "RequestVideoEvents" ON "RequestVideoEvents"."videoId" = "Videos"."id"' + + Pod.listRandomPodIdsWithRequest(limitPods, 'Authors', podJoins, function (err, podIds) { + if (err) return callback(err) + + // We don't have friends that have requests + if (podIds.length === 0) return callback(null, []) + + const query = { + order: [ + [ 'id', 'ASC' ] + ], + include: [ + { + model: self.sequelize.models.Video, + include: [ + { + model: self.sequelize.models.Author, + include: [ + { + model: self.sequelize.models.Pod, + where: { + id: { + $in: podIds + } + } + } + ] + } + ] + } + ] + } + + self.findAll(query).asCallback(function (err, requests) { + if (err) return callback(err) + + const requestsGrouped = groupAndTruncateRequests(requests, limitRequestsPerPod) + return callback(err, requestsGrouped) + }) + }) +} + +function removeByRequestIdsAndPod (ids, podId, callback) { + const query = { + where: { + id: { + $in: ids + } + }, + include: [ + { + model: this.sequelize.models.Video, + include: [ + { + model: this.sequelize.models.Author, + where: { + podId + } + } + ] + } + ] + } + + this.destroy(query).asCallback(callback) +} + +function removeAll (callback) { + // Delete all requests + this.truncate({ cascade: true }).asCallback(callback) +} + +// --------------------------------------------------------------------------- + +function groupAndTruncateRequests (events, limitRequestsPerPod) { + const eventsGrouped = {} + + events.forEach(function (event) { + const pod = event.Video.Author.Pod + + if (!eventsGrouped[pod.id]) eventsGrouped[pod.id] = [] + + if (eventsGrouped[pod.id].length < limitRequestsPerPod) { + eventsGrouped[pod.id].push({ + id: event.id, + type: event.type, + count: event.count, + video: event.Video, + pod + }) + } + }) + + return eventsGrouped +} diff --git a/server/models/request-video-qadu.js b/server/models/request-video-qadu.js deleted file mode 100644 index 5d88738aa..000000000 --- a/server/models/request-video-qadu.js +++ /dev/null @@ -1,151 +0,0 @@ -'use strict' - -/* - Request Video for Quick And Dirty Updates like: - - views - - likes - - dislikes - - We can't put it in the same system than basic requests for efficiency. - Moreover we don't want to slow down the basic requests with a lot of views/likes/dislikes requests. - So we put it an independant request scheduler. -*/ - -const values = require('lodash/values') - -const constants = require('../initializers/constants') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const RequestVideoQadu = sequelize.define('RequestVideoQadu', - { - type: { - type: DataTypes.ENUM(values(constants.REQUEST_VIDEO_QADU_TYPES)), - allowNull: false - } - }, - { - timestamps: false, - indexes: [ - { - fields: [ 'podId' ] - }, - { - fields: [ 'videoId' ] - } - ], - classMethods: { - associate, - - listWithLimitAndRandom, - - countTotalRequests, - removeAll, - removeByRequestIdsAndPod - } - } - ) - - return RequestVideoQadu -} - -// ------------------------------ STATICS ------------------------------ - -function associate (models) { - this.belongsTo(models.Pod, { - foreignKey: { - name: 'podId', - allowNull: false - }, - onDelete: 'CASCADE' - }) - - this.belongsTo(models.Video, { - foreignKey: { - name: 'videoId', - allowNull: false - }, - onDelete: 'CASCADE' - }) -} - -function countTotalRequests (callback) { - const query = {} - return this.count(query).asCallback(callback) -} - -function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { - const self = this - const Pod = this.sequelize.models.Pod - - Pod.listRandomPodIdsWithRequest(limitPods, 'RequestVideoQadus', function (err, podIds) { - if (err) return callback(err) - - // We don't have friends that have requests - if (podIds.length === 0) return callback(null, []) - - const query = { - include: [ - { - model: self.sequelize.models.Pod, - where: { - id: { - $in: podIds - } - } - }, - { - model: self.sequelize.models.Video - } - ] - } - - self.findAll(query).asCallback(function (err, requests) { - if (err) return callback(err) - - const requestsGrouped = groupAndTruncateRequests(requests, limitRequestsPerPod) - return callback(err, requestsGrouped) - }) - }) -} - -function removeByRequestIdsAndPod (ids, podId, callback) { - const query = { - where: { - id: { - $in: ids - }, - podId - } - } - - this.destroy(query).asCallback(callback) -} - -function removeAll (callback) { - // Delete all requests - this.truncate({ cascade: true }).asCallback(callback) -} - -// --------------------------------------------------------------------------- - -function groupAndTruncateRequests (requests, limitRequestsPerPod) { - const requestsGrouped = {} - - requests.forEach(function (request) { - const pod = request.Pod - - if (!requestsGrouped[pod.id]) requestsGrouped[pod.id] = [] - - if (requestsGrouped[pod.id].length < limitRequestsPerPod) { - requestsGrouped[pod.id].push({ - request: request, - video: request.Video, - pod - }) - } - }) - - return requestsGrouped -} diff --git a/server/models/request-video-qadu.ts b/server/models/request-video-qadu.ts new file mode 100644 index 000000000..2b1ed07c9 --- /dev/null +++ b/server/models/request-video-qadu.ts @@ -0,0 +1,149 @@ +/* + Request Video for Quick And Dirty Updates like: + - views + - likes + - dislikes + + We can't put it in the same system than basic requests for efficiency. + Moreover we don't want to slow down the basic requests with a lot of views/likes/dislikes requests. + So we put it an independant request scheduler. +*/ + +import { values } from 'lodash' + +import { REQUEST_VIDEO_QADU_TYPES } from '../initializers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const RequestVideoQadu = sequelize.define('RequestVideoQadu', + { + type: { + type: DataTypes.ENUM(values(REQUEST_VIDEO_QADU_TYPES)), + allowNull: false + } + }, + { + timestamps: false, + indexes: [ + { + fields: [ 'podId' ] + }, + { + fields: [ 'videoId' ] + } + ], + classMethods: { + associate, + + listWithLimitAndRandom, + + countTotalRequests, + removeAll, + removeByRequestIdsAndPod + } + } + ) + + return RequestVideoQadu +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + this.belongsTo(models.Pod, { + foreignKey: { + name: 'podId', + allowNull: false + }, + onDelete: 'CASCADE' + }) + + this.belongsTo(models.Video, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'CASCADE' + }) +} + +function countTotalRequests (callback) { + const query = {} + return this.count(query).asCallback(callback) +} + +function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { + const self = this + const Pod = this.sequelize.models.Pod + + Pod.listRandomPodIdsWithRequest(limitPods, 'RequestVideoQadus', function (err, podIds) { + if (err) return callback(err) + + // We don't have friends that have requests + if (podIds.length === 0) return callback(null, []) + + const query = { + include: [ + { + model: self.sequelize.models.Pod, + where: { + id: { + $in: podIds + } + } + }, + { + model: self.sequelize.models.Video + } + ] + } + + self.findAll(query).asCallback(function (err, requests) { + if (err) return callback(err) + + const requestsGrouped = groupAndTruncateRequests(requests, limitRequestsPerPod) + return callback(err, requestsGrouped) + }) + }) +} + +function removeByRequestIdsAndPod (ids, podId, callback) { + const query = { + where: { + id: { + $in: ids + }, + podId + } + } + + this.destroy(query).asCallback(callback) +} + +function removeAll (callback) { + // Delete all requests + this.truncate({ cascade: true }).asCallback(callback) +} + +// --------------------------------------------------------------------------- + +function groupAndTruncateRequests (requests, limitRequestsPerPod) { + const requestsGrouped = {} + + requests.forEach(function (request) { + const pod = request.Pod + + if (!requestsGrouped[pod.id]) requestsGrouped[pod.id] = [] + + if (requestsGrouped[pod.id].length < limitRequestsPerPod) { + requestsGrouped[pod.id].push({ + request: request, + video: request.Video, + pod + }) + } + }) + + return requestsGrouped +} diff --git a/server/models/request.js b/server/models/request.js deleted file mode 100644 index 3a047f7ee..000000000 --- a/server/models/request.js +++ /dev/null @@ -1,137 +0,0 @@ -'use strict' - -const values = require('lodash/values') - -const constants = require('../initializers/constants') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const Request = sequelize.define('Request', - { - request: { - type: DataTypes.JSON, - allowNull: false - }, - endpoint: { - type: DataTypes.ENUM(values(constants.REQUEST_ENDPOINTS)), - allowNull: false - } - }, - { - classMethods: { - associate, - - listWithLimitAndRandom, - - countTotalRequests, - removeAll, - removeWithEmptyTo - } - } - ) - - return Request -} - -// ------------------------------ STATICS ------------------------------ - -function associate (models) { - this.belongsToMany(models.Pod, { - foreignKey: { - name: 'requestId', - allowNull: false - }, - through: models.RequestToPod, - onDelete: 'CASCADE' - }) -} - -function countTotalRequests (callback) { - // We need to include Pod because there are no cascade delete when a pod is removed - // So we could count requests that do not have existing pod anymore - const query = { - include: [ this.sequelize.models.Pod ] - } - - return this.count(query).asCallback(callback) -} - -function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { - const self = this - const Pod = this.sequelize.models.Pod - - Pod.listRandomPodIdsWithRequest(limitPods, 'RequestToPods', function (err, podIds) { - if (err) return callback(err) - - // We don't have friends that have requests - if (podIds.length === 0) return callback(null, []) - - // The first x requests of these pods - // It is very important to sort by id ASC to keep the requests order! - const query = { - order: [ - [ 'id', 'ASC' ] - ], - include: [ - { - model: self.sequelize.models.Pod, - where: { - id: { - $in: podIds - } - } - } - ] - } - - self.findAll(query).asCallback(function (err, requests) { - if (err) return callback(err) - - const requestsGrouped = groupAndTruncateRequests(requests, limitRequestsPerPod) - return callback(err, requestsGrouped) - }) - }) -} - -function removeAll (callback) { - // Delete all requests - this.truncate({ cascade: true }).asCallback(callback) -} - -function removeWithEmptyTo (callback) { - if (!callback) callback = function () {} - - const query = { - where: { - id: { - $notIn: [ - this.sequelize.literal('SELECT "requestId" FROM "RequestToPods"') - ] - } - } - } - - this.destroy(query).asCallback(callback) -} - -// --------------------------------------------------------------------------- - -function groupAndTruncateRequests (requests, limitRequestsPerPod) { - const requestsGrouped = {} - - requests.forEach(function (request) { - request.Pods.forEach(function (pod) { - if (!requestsGrouped[pod.id]) requestsGrouped[pod.id] = [] - - if (requestsGrouped[pod.id].length < limitRequestsPerPod) { - requestsGrouped[pod.id].push({ - request, - pod - }) - } - }) - }) - - return requestsGrouped -} diff --git a/server/models/request.ts b/server/models/request.ts new file mode 100644 index 000000000..672f79d11 --- /dev/null +++ b/server/models/request.ts @@ -0,0 +1,135 @@ +import { values } from 'lodash' + +import { REQUEST_ENDPOINTS } from '../initializers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const Request = sequelize.define('Request', + { + request: { + type: DataTypes.JSON, + allowNull: false + }, + endpoint: { + type: DataTypes.ENUM(values(REQUEST_ENDPOINTS)), + allowNull: false + } + }, + { + classMethods: { + associate, + + listWithLimitAndRandom, + + countTotalRequests, + removeAll, + removeWithEmptyTo + } + } + ) + + return Request +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + this.belongsToMany(models.Pod, { + foreignKey: { + name: 'requestId', + allowNull: false + }, + through: models.RequestToPod, + onDelete: 'CASCADE' + }) +} + +function countTotalRequests (callback) { + // We need to include Pod because there are no cascade delete when a pod is removed + // So we could count requests that do not have existing pod anymore + const query = { + include: [ this.sequelize.models.Pod ] + } + + return this.count(query).asCallback(callback) +} + +function listWithLimitAndRandom (limitPods, limitRequestsPerPod, callback) { + const self = this + const Pod = this.sequelize.models.Pod + + Pod.listRandomPodIdsWithRequest(limitPods, 'RequestToPods', function (err, podIds) { + if (err) return callback(err) + + // We don't have friends that have requests + if (podIds.length === 0) return callback(null, []) + + // The first x requests of these pods + // It is very important to sort by id ASC to keep the requests order! + const query = { + order: [ + [ 'id', 'ASC' ] + ], + include: [ + { + model: self.sequelize.models.Pod, + where: { + id: { + $in: podIds + } + } + } + ] + } + + self.findAll(query).asCallback(function (err, requests) { + if (err) return callback(err) + + const requestsGrouped = groupAndTruncateRequests(requests, limitRequestsPerPod) + return callback(err, requestsGrouped) + }) + }) +} + +function removeAll (callback) { + // Delete all requests + this.truncate({ cascade: true }).asCallback(callback) +} + +function removeWithEmptyTo (callback) { + if (!callback) callback = function () { /* empty */ } + + const query = { + where: { + id: { + $notIn: [ + this.sequelize.literal('SELECT "requestId" FROM "RequestToPods"') + ] + } + } + } + + this.destroy(query).asCallback(callback) +} + +// --------------------------------------------------------------------------- + +function groupAndTruncateRequests (requests, limitRequestsPerPod) { + const requestsGrouped = {} + + requests.forEach(function (request) { + request.Pods.forEach(function (pod) { + if (!requestsGrouped[pod.id]) requestsGrouped[pod.id] = [] + + if (requestsGrouped[pod.id].length < limitRequestsPerPod) { + requestsGrouped[pod.id].push({ + request, + pod + }) + } + }) + }) + + return requestsGrouped +} diff --git a/server/models/tag.js b/server/models/tag.js deleted file mode 100644 index 145e090c1..000000000 --- a/server/models/tag.js +++ /dev/null @@ -1,76 +0,0 @@ -'use strict' - -const each = require('async/each') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const Tag = sequelize.define('Tag', - { - name: { - type: DataTypes.STRING, - allowNull: false - } - }, - { - timestamps: false, - indexes: [ - { - fields: [ 'name' ], - unique: true - } - ], - classMethods: { - associate, - - findOrCreateTags - } - } - ) - - return Tag -} - -// --------------------------------------------------------------------------- - -function associate (models) { - this.belongsToMany(models.Video, { - foreignKey: 'tagId', - through: models.VideoTag, - onDelete: 'cascade' - }) -} - -function findOrCreateTags (tags, transaction, callback) { - if (!callback) { - callback = transaction - transaction = null - } - - const self = this - const tagInstances = [] - - each(tags, function (tag, callbackEach) { - const query = { - where: { - name: tag - }, - defaults: { - name: tag - } - } - - if (transaction) query.transaction = transaction - - self.findOrCreate(query).asCallback(function (err, res) { - if (err) return callbackEach(err) - - // res = [ tag, isCreated ] - const tag = res[0] - tagInstances.push(tag) - return callbackEach() - }) - }, function (err) { - return callback(err, tagInstances) - }) -} diff --git a/server/models/tag.ts b/server/models/tag.ts new file mode 100644 index 000000000..85a0442d2 --- /dev/null +++ b/server/models/tag.ts @@ -0,0 +1,74 @@ +import { each } from 'async' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const Tag = sequelize.define('Tag', + { + name: { + type: DataTypes.STRING, + allowNull: false + } + }, + { + timestamps: false, + indexes: [ + { + fields: [ 'name' ], + unique: true + } + ], + classMethods: { + associate, + + findOrCreateTags + } + } + ) + + return Tag +} + +// --------------------------------------------------------------------------- + +function associate (models) { + this.belongsToMany(models.Video, { + foreignKey: 'tagId', + through: models.VideoTag, + onDelete: 'cascade' + }) +} + +function findOrCreateTags (tags, transaction, callback) { + if (!callback) { + callback = transaction + transaction = null + } + + const self = this + const tagInstances = [] + + each(tags, function (tag, callbackEach) { + const query: any = { + where: { + name: tag + }, + defaults: { + name: tag + } + } + + if (transaction) query.transaction = transaction + + self.findOrCreate(query).asCallback(function (err, res) { + if (err) return callbackEach(err) + + // res = [ tag, isCreated ] + const tag = res[0] + tagInstances.push(tag) + return callbackEach() + }) + }, function (err) { + return callback(err, tagInstances) + }) +} diff --git a/server/models/user-video-rate.js b/server/models/user-video-rate.js deleted file mode 100644 index 84007d70c..000000000 --- a/server/models/user-video-rate.js +++ /dev/null @@ -1,77 +0,0 @@ -'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/user-video-rate.ts b/server/models/user-video-rate.ts new file mode 100644 index 000000000..6603c7862 --- /dev/null +++ b/server/models/user-video-rate.ts @@ -0,0 +1,74 @@ +/* + User rates per video. + +*/ +import { values } from 'lodash' + +import { VIDEO_RATE_TYPES } from '../initializers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const UserVideoRate = sequelize.define('UserVideoRate', + { + type: { + type: DataTypes.ENUM(values(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: any = {} + if (transaction) options.transaction = transaction + + return this.findOne(query, options).asCallback(callback) +} diff --git a/server/models/user.js b/server/models/user.js deleted file mode 100644 index 8f9c2bf65..000000000 --- a/server/models/user.js +++ /dev/null @@ -1,194 +0,0 @@ -'use strict' - -const values = require('lodash/values') - -const modelUtils = require('./utils') -const constants = require('../initializers/constants') -const peertubeCrypto = require('../helpers/peertube-crypto') -const customUsersValidators = require('../helpers/custom-validators').users - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const User = sequelize.define('User', - { - password: { - type: DataTypes.STRING, - allowNull: false, - validate: { - passwordValid: function (value) { - const res = customUsersValidators.isUserPasswordValid(value) - if (res === false) throw new Error('Password not valid.') - } - } - }, - username: { - type: DataTypes.STRING, - allowNull: false, - validate: { - usernameValid: function (value) { - const res = customUsersValidators.isUserUsernameValid(value) - if (res === false) throw new Error('Username not valid.') - } - } - }, - email: { - type: DataTypes.STRING(400), - allowNull: false, - validate: { - isEmail: true - } - }, - displayNSFW: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - validate: { - nsfwValid: function (value) { - const res = customUsersValidators.isUserDisplayNSFWValid(value) - if (res === false) throw new Error('Display NSFW is not valid.') - } - } - }, - role: { - type: DataTypes.ENUM(values(constants.USER_ROLES)), - allowNull: false - } - }, - { - indexes: [ - { - fields: [ 'username' ], - unique: true - }, - { - fields: [ 'email' ], - unique: true - } - ], - classMethods: { - associate, - - countTotal, - getByUsername, - list, - listForApi, - loadById, - loadByUsername, - loadByUsernameOrEmail - }, - instanceMethods: { - isPasswordMatch, - toFormatedJSON, - isAdmin - }, - hooks: { - beforeCreate: beforeCreateOrUpdate, - beforeUpdate: beforeCreateOrUpdate - } - } - ) - - return User -} - -function beforeCreateOrUpdate (user, options, next) { - peertubeCrypto.cryptPassword(user.password, function (err, hash) { - if (err) return next(err) - - user.password = hash - - return next() - }) -} - -// ------------------------------ METHODS ------------------------------ - -function isPasswordMatch (password, callback) { - return peertubeCrypto.comparePassword(password, this.password, callback) -} - -function toFormatedJSON () { - return { - id: this.id, - username: this.username, - email: this.email, - displayNSFW: this.displayNSFW, - role: this.role, - createdAt: this.createdAt - } -} - -function isAdmin () { - return this.role === constants.USER_ROLES.ADMIN -} - -// ------------------------------ STATICS ------------------------------ - -function associate (models) { - this.hasOne(models.Author, { - foreignKey: 'userId', - onDelete: 'cascade' - }) - - this.hasMany(models.OAuthToken, { - foreignKey: 'userId', - onDelete: 'cascade' - }) -} - -function countTotal (callback) { - return this.count().asCallback(callback) -} - -function getByUsername (username) { - const query = { - where: { - username: username - } - } - - return this.findOne(query) -} - -function list (callback) { - return this.find().asCallback(callback) -} - -function listForApi (start, count, sort, callback) { - const query = { - offset: start, - limit: count, - order: [ modelUtils.getSort(sort) ] - } - - return this.findAndCountAll(query).asCallback(function (err, result) { - if (err) return callback(err) - - return callback(null, result.rows, result.count) - }) -} - -function loadById (id, callback) { - return this.findById(id).asCallback(callback) -} - -function loadByUsername (username, callback) { - const query = { - where: { - username: username - } - } - - return this.findOne(query).asCallback(callback) -} - -function loadByUsernameOrEmail (username, email, callback) { - const query = { - where: { - $or: [ { username }, { email } ] - } - } - - return this.findOne(query).asCallback(callback) -} diff --git a/server/models/user.ts b/server/models/user.ts new file mode 100644 index 000000000..d63a50cc4 --- /dev/null +++ b/server/models/user.ts @@ -0,0 +1,197 @@ +import { values } from 'lodash' + +import { getSort } from './utils' +import { USER_ROLES } from '../initializers' +import { + cryptPassword, + comparePassword, + isUserPasswordValid, + isUserUsernameValid, + isUserDisplayNSFWValid +} from '../helpers' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const User = sequelize.define('User', + { + password: { + type: DataTypes.STRING, + allowNull: false, + validate: { + passwordValid: function (value) { + const res = isUserPasswordValid(value) + if (res === false) throw new Error('Password not valid.') + } + } + }, + username: { + type: DataTypes.STRING, + allowNull: false, + validate: { + usernameValid: function (value) { + const res = isUserUsernameValid(value) + if (res === false) throw new Error('Username not valid.') + } + } + }, + email: { + type: DataTypes.STRING(400), + allowNull: false, + validate: { + isEmail: true + } + }, + displayNSFW: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + validate: { + nsfwValid: function (value) { + const res = isUserDisplayNSFWValid(value) + if (res === false) throw new Error('Display NSFW is not valid.') + } + } + }, + role: { + type: DataTypes.ENUM(values(USER_ROLES)), + allowNull: false + } + }, + { + indexes: [ + { + fields: [ 'username' ], + unique: true + }, + { + fields: [ 'email' ], + unique: true + } + ], + classMethods: { + associate, + + countTotal, + getByUsername, + list, + listForApi, + loadById, + loadByUsername, + loadByUsernameOrEmail + }, + instanceMethods: { + isPasswordMatch, + toFormatedJSON, + isAdmin + }, + hooks: { + beforeCreate: beforeCreateOrUpdate, + beforeUpdate: beforeCreateOrUpdate + } + } + ) + + return User +} + +function beforeCreateOrUpdate (user, options, next) { + cryptPassword(user.password, function (err, hash) { + if (err) return next(err) + + user.password = hash + + return next() + }) +} + +// ------------------------------ METHODS ------------------------------ + +function isPasswordMatch (password, callback) { + return comparePassword(password, this.password, callback) +} + +function toFormatedJSON () { + return { + id: this.id, + username: this.username, + email: this.email, + displayNSFW: this.displayNSFW, + role: this.role, + createdAt: this.createdAt + } +} + +function isAdmin () { + return this.role === USER_ROLES.ADMIN +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + this.hasOne(models.Author, { + foreignKey: 'userId', + onDelete: 'cascade' + }) + + this.hasMany(models.OAuthToken, { + foreignKey: 'userId', + onDelete: 'cascade' + }) +} + +function countTotal (callback) { + return this.count().asCallback(callback) +} + +function getByUsername (username) { + const query = { + where: { + username: username + } + } + + return this.findOne(query) +} + +function list (callback) { + return this.find().asCallback(callback) +} + +function listForApi (start, count, sort, callback) { + const query = { + offset: start, + limit: count, + order: [ getSort(sort) ] + } + + return this.findAndCountAll(query).asCallback(function (err, result) { + if (err) return callback(err) + + return callback(null, result.rows, result.count) + }) +} + +function loadById (id, callback) { + return this.findById(id).asCallback(callback) +} + +function loadByUsername (username, callback) { + const query = { + where: { + username: username + } + } + + return this.findOne(query).asCallback(callback) +} + +function loadByUsernameOrEmail (username, email, callback) { + const query = { + where: { + $or: [ { username }, { email } ] + } + } + + return this.findOne(query).asCallback(callback) +} diff --git a/server/models/utils.js b/server/models/utils.js deleted file mode 100644 index 49636b3d8..000000000 --- a/server/models/utils.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict' - -const utils = { - getSort -} - -// Translate for example "-name" to [ 'name', 'DESC' ] -function getSort (value) { - let field - let direction - - if (value.substring(0, 1) === '-') { - direction = 'DESC' - field = value.substring(1) - } else { - direction = 'ASC' - field = value - } - - return [ field, direction ] -} - -// --------------------------------------------------------------------------- - -module.exports = utils diff --git a/server/models/utils.ts b/server/models/utils.ts new file mode 100644 index 000000000..601811913 --- /dev/null +++ b/server/models/utils.ts @@ -0,0 +1,21 @@ +// Translate for example "-name" to [ 'name', 'DESC' ] +function getSort (value) { + let field + let direction + + if (value.substring(0, 1) === '-') { + direction = 'DESC' + field = value.substring(1) + } else { + direction = 'ASC' + field = value + } + + return [ field, direction ] +} + +// --------------------------------------------------------------------------- + +export { + getSort +} diff --git a/server/models/video-abuse.js b/server/models/video-abuse.js deleted file mode 100644 index 67cead3af..000000000 --- a/server/models/video-abuse.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict' - -const constants = require('../initializers/constants') -const modelUtils = require('./utils') -const customVideosValidators = require('../helpers/custom-validators').videos - -module.exports = function (sequelize, DataTypes) { - const VideoAbuse = sequelize.define('VideoAbuse', - { - reporterUsername: { - type: DataTypes.STRING, - allowNull: false, - validate: { - reporterUsernameValid: function (value) { - const res = customVideosValidators.isVideoAbuseReporterUsernameValid(value) - if (res === false) throw new Error('Video abuse reporter username is not valid.') - } - } - }, - reason: { - type: DataTypes.STRING, - allowNull: false, - validate: { - reasonValid: function (value) { - const res = customVideosValidators.isVideoAbuseReasonValid(value) - if (res === false) throw new Error('Video abuse reason is not valid.') - } - } - } - }, - { - indexes: [ - { - fields: [ 'videoId' ] - }, - { - fields: [ 'reporterPodId' ] - } - ], - classMethods: { - associate, - - listForApi - }, - instanceMethods: { - toFormatedJSON - } - } - ) - - return VideoAbuse -} - -// --------------------------------------------------------------------------- - -function associate (models) { - this.belongsTo(models.Pod, { - foreignKey: { - name: 'reporterPodId', - allowNull: true - }, - onDelete: 'cascade' - }) - - this.belongsTo(models.Video, { - foreignKey: { - name: 'videoId', - allowNull: false - }, - onDelete: 'cascade' - }) -} - -function listForApi (start, count, sort, callback) { - const query = { - offset: start, - limit: count, - order: [ modelUtils.getSort(sort) ], - include: [ - { - model: this.sequelize.models.Pod, - required: false - } - ] - } - - return this.findAndCountAll(query).asCallback(function (err, result) { - if (err) return callback(err) - - return callback(null, result.rows, result.count) - }) -} - -function toFormatedJSON () { - let reporterPodHost - - if (this.Pod) { - reporterPodHost = this.Pod.host - } else { - // It means it's our video - reporterPodHost = constants.CONFIG.WEBSERVER.HOST - } - - const json = { - id: this.id, - reporterPodHost, - reason: this.reason, - reporterUsername: this.reporterUsername, - videoId: this.videoId, - createdAt: this.createdAt - } - - return json -} diff --git a/server/models/video-abuse.ts b/server/models/video-abuse.ts new file mode 100644 index 000000000..2a18a293d --- /dev/null +++ b/server/models/video-abuse.ts @@ -0,0 +1,112 @@ +import { CONFIG } from '../initializers' +import { isVideoAbuseReporterUsernameValid, isVideoAbuseReasonValid } from '../helpers' +import { getSort } from './utils' + +module.exports = function (sequelize, DataTypes) { + const VideoAbuse = sequelize.define('VideoAbuse', + { + reporterUsername: { + type: DataTypes.STRING, + allowNull: false, + validate: { + reporterUsernameValid: function (value) { + const res = isVideoAbuseReporterUsernameValid(value) + if (res === false) throw new Error('Video abuse reporter username is not valid.') + } + } + }, + reason: { + type: DataTypes.STRING, + allowNull: false, + validate: { + reasonValid: function (value) { + const res = isVideoAbuseReasonValid(value) + if (res === false) throw new Error('Video abuse reason is not valid.') + } + } + } + }, + { + indexes: [ + { + fields: [ 'videoId' ] + }, + { + fields: [ 'reporterPodId' ] + } + ], + classMethods: { + associate, + + listForApi + }, + instanceMethods: { + toFormatedJSON + } + } + ) + + return VideoAbuse +} + +// --------------------------------------------------------------------------- + +function associate (models) { + this.belongsTo(models.Pod, { + foreignKey: { + name: 'reporterPodId', + allowNull: true + }, + onDelete: 'cascade' + }) + + this.belongsTo(models.Video, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) +} + +function listForApi (start, count, sort, callback) { + const query = { + offset: start, + limit: count, + order: [ getSort(sort) ], + include: [ + { + model: this.sequelize.models.Pod, + required: false + } + ] + } + + return this.findAndCountAll(query).asCallback(function (err, result) { + if (err) return callback(err) + + return callback(null, result.rows, result.count) + }) +} + +function toFormatedJSON () { + let reporterPodHost + + if (this.Pod) { + reporterPodHost = this.Pod.host + } else { + // It means it's our video + reporterPodHost = CONFIG.WEBSERVER.HOST + } + + const json = { + id: this.id, + reporterPodHost, + reason: this.reason, + reporterUsername: this.reporterUsername, + videoId: this.videoId, + createdAt: this.createdAt + } + + return json +} diff --git a/server/models/video-blacklist.js b/server/models/video-blacklist.js deleted file mode 100644 index 02ea15760..000000000 --- a/server/models/video-blacklist.js +++ /dev/null @@ -1,89 +0,0 @@ -'use strict' - -const modelUtils = require('./utils') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const BlacklistedVideo = sequelize.define('BlacklistedVideo', - {}, - { - indexes: [ - { - fields: [ 'videoId' ], - unique: true - } - ], - classMethods: { - associate, - - countTotal, - list, - listForApi, - loadById, - loadByVideoId - }, - instanceMethods: { - toFormatedJSON - }, - hooks: {} - } - ) - - return BlacklistedVideo -} - -// ------------------------------ METHODS ------------------------------ - -function toFormatedJSON () { - return { - id: this.id, - videoId: this.videoId, - createdAt: this.createdAt - } -} - -// ------------------------------ STATICS ------------------------------ - -function associate (models) { - this.belongsTo(models.Video, { - foreignKey: 'videoId', - onDelete: 'cascade' - }) -} - -function countTotal (callback) { - return this.count().asCallback(callback) -} - -function list (callback) { - return this.findAll().asCallback(callback) -} - -function listForApi (start, count, sort, callback) { - const query = { - offset: start, - limit: count, - order: [ modelUtils.getSort(sort) ] - } - - return this.findAndCountAll(query).asCallback(function (err, result) { - if (err) return callback(err) - - return callback(null, result.rows, result.count) - }) -} - -function loadById (id, callback) { - return this.findById(id).asCallback(callback) -} - -function loadByVideoId (id, callback) { - const query = { - where: { - videoId: id - } - } - - return this.find(query).asCallback(callback) -} diff --git a/server/models/video-blacklist.ts b/server/models/video-blacklist.ts new file mode 100644 index 000000000..1f00702c7 --- /dev/null +++ b/server/models/video-blacklist.ts @@ -0,0 +1,87 @@ +import { getSort } from './utils' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const BlacklistedVideo = sequelize.define('BlacklistedVideo', + {}, + { + indexes: [ + { + fields: [ 'videoId' ], + unique: true + } + ], + classMethods: { + associate, + + countTotal, + list, + listForApi, + loadById, + loadByVideoId + }, + instanceMethods: { + toFormatedJSON + }, + hooks: {} + } + ) + + return BlacklistedVideo +} + +// ------------------------------ METHODS ------------------------------ + +function toFormatedJSON () { + return { + id: this.id, + videoId: this.videoId, + createdAt: this.createdAt + } +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + this.belongsTo(models.Video, { + foreignKey: 'videoId', + onDelete: 'cascade' + }) +} + +function countTotal (callback) { + return this.count().asCallback(callback) +} + +function list (callback) { + return this.findAll().asCallback(callback) +} + +function listForApi (start, count, sort, callback) { + const query = { + offset: start, + limit: count, + order: [ getSort(sort) ] + } + + return this.findAndCountAll(query).asCallback(function (err, result) { + if (err) return callback(err) + + return callback(null, result.rows, result.count) + }) +} + +function loadById (id, callback) { + return this.findById(id).asCallback(callback) +} + +function loadByVideoId (id, callback) { + const query = { + where: { + videoId: id + } + } + + return this.find(query).asCallback(callback) +} diff --git a/server/models/video-tag.js b/server/models/video-tag.js deleted file mode 100644 index cd9277a6e..000000000 --- a/server/models/video-tag.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict' - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const VideoTag = sequelize.define('VideoTag', {}, { - indexes: [ - { - fields: [ 'videoId' ] - }, - { - fields: [ 'tagId' ] - } - ] - }) - - return VideoTag -} diff --git a/server/models/video-tag.ts b/server/models/video-tag.ts new file mode 100644 index 000000000..83ff6053f --- /dev/null +++ b/server/models/video-tag.ts @@ -0,0 +1,14 @@ +module.exports = function (sequelize, DataTypes) { + const VideoTag = sequelize.define('VideoTag', {}, { + indexes: [ + { + fields: [ 'videoId' ] + }, + { + fields: [ 'tagId' ] + } + ] + }) + + return VideoTag +} diff --git a/server/models/video.js b/server/models/video.js deleted file mode 100644 index da4ddb420..000000000 --- a/server/models/video.js +++ /dev/null @@ -1,858 +0,0 @@ -'use strict' - -const Buffer = require('safe-buffer').Buffer -const createTorrent = require('create-torrent') -const ffmpeg = require('fluent-ffmpeg') -const fs = require('fs') -const magnetUtil = require('magnet-uri') -const map = require('lodash/map') -const parallel = require('async/parallel') -const series = require('async/series') -const parseTorrent = require('parse-torrent') -const pathUtils = require('path') -const values = require('lodash/values') - -const constants = require('../initializers/constants') -const logger = require('../helpers/logger') -const friends = require('../lib/friends') -const modelUtils = require('./utils') -const customVideosValidators = require('../helpers/custom-validators').videos -const db = require('../initializers/database') -const jobScheduler = require('../lib/jobs/job-scheduler') - -// --------------------------------------------------------------------------- - -module.exports = function (sequelize, DataTypes) { - const Video = sequelize.define('Video', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - validate: { - isUUID: 4 - } - }, - name: { - type: DataTypes.STRING, - allowNull: false, - validate: { - nameValid: function (value) { - const res = customVideosValidators.isVideoNameValid(value) - if (res === false) throw new Error('Video name is not valid.') - } - } - }, - extname: { - type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)), - allowNull: false - }, - remoteId: { - type: DataTypes.UUID, - allowNull: true, - validate: { - isUUID: 4 - } - }, - category: { - type: DataTypes.INTEGER, - allowNull: false, - validate: { - categoryValid: function (value) { - const res = customVideosValidators.isVideoCategoryValid(value) - if (res === false) throw new Error('Video category is not valid.') - } - } - }, - licence: { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: null, - validate: { - licenceValid: function (value) { - const res = customVideosValidators.isVideoLicenceValid(value) - if (res === false) throw new Error('Video licence is not valid.') - } - } - }, - language: { - type: DataTypes.INTEGER, - allowNull: true, - validate: { - languageValid: function (value) { - const res = customVideosValidators.isVideoLanguageValid(value) - if (res === false) throw new Error('Video language is not valid.') - } - } - }, - nsfw: { - type: DataTypes.BOOLEAN, - allowNull: false, - validate: { - nsfwValid: function (value) { - const res = customVideosValidators.isVideoNSFWValid(value) - if (res === false) throw new Error('Video nsfw attribute is not valid.') - } - } - }, - description: { - type: DataTypes.STRING, - allowNull: false, - validate: { - descriptionValid: function (value) { - const res = customVideosValidators.isVideoDescriptionValid(value) - if (res === false) throw new Error('Video description is not valid.') - } - } - }, - infoHash: { - type: DataTypes.STRING, - allowNull: false, - validate: { - infoHashValid: function (value) { - const res = customVideosValidators.isVideoInfoHashValid(value) - if (res === false) throw new Error('Video info hash is not valid.') - } - } - }, - duration: { - type: DataTypes.INTEGER, - allowNull: false, - validate: { - durationValid: function (value) { - const res = customVideosValidators.isVideoDurationValid(value) - if (res === false) throw new Error('Video duration is not valid.') - } - } - }, - views: { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - validate: { - 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 - } - } - }, - { - indexes: [ - { - fields: [ 'authorId' ] - }, - { - fields: [ 'remoteId' ] - }, - { - fields: [ 'name' ] - }, - { - fields: [ 'createdAt' ] - }, - { - fields: [ 'duration' ] - }, - { - fields: [ 'infoHash' ] - }, - { - fields: [ 'views' ] - }, - { - fields: [ 'likes' ] - } - ], - classMethods: { - associate, - - generateThumbnailFromData, - getDurationFromFile, - list, - listForApi, - listOwnedAndPopulateAuthorAndTags, - listOwnedByAuthor, - load, - loadByHostAndRemoteId, - loadAndPopulateAuthor, - loadAndPopulateAuthorAndPodAndTags, - searchAndPopulateAuthorAndPodAndTags - }, - instanceMethods: { - generateMagnetUri, - getVideoFilename, - getThumbnailName, - getPreviewName, - getTorrentName, - isOwned, - toFormatedJSON, - toAddRemoteJSON, - toUpdateRemoteJSON, - transcodeVideofile, - removeFromBlacklist - }, - hooks: { - beforeValidate, - beforeCreate, - afterDestroy - } - } - ) - - return Video -} - -function beforeValidate (video, options, next) { - // Put a fake infoHash if it does not exists yet - if (video.isOwned() && !video.infoHash) { - // 40 hexa length - video.infoHash = '0123456789abcdef0123456789abcdef01234567' - } - - return next(null) -} - -function beforeCreate (video, options, next) { - const tasks = [] - - if (video.isOwned()) { - const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) - - tasks.push( - function createVideoTorrent (callback) { - createTorrentFromVideo(video, videoPath, callback) - }, - - function createVideoThumbnail (callback) { - createThumbnail(video, videoPath, callback) - }, - - function createVideoPreview (callback) { - createPreview(video, videoPath, callback) - } - ) - - if (constants.CONFIG.TRANSCODING.ENABLED === true) { - tasks.push( - function createVideoTranscoderJob (callback) { - const dataInput = { - id: video.id - } - - jobScheduler.createJob(options.transaction, 'videoTranscoder', dataInput, callback) - } - ) - } - - return parallel(tasks, next) - } - - return next() -} - -function afterDestroy (video, options, next) { - const tasks = [] - - tasks.push( - function (callback) { - removeThumbnail(video, callback) - } - ) - - if (video.isOwned()) { - tasks.push( - function removeVideoFile (callback) { - removeFile(video, callback) - }, - - function removeVideoTorrent (callback) { - removeTorrent(video, callback) - }, - - function removeVideoPreview (callback) { - removePreview(video, callback) - }, - - function removeVideoToFriends (callback) { - const params = { - remoteId: video.id - } - - friends.removeVideoToFriends(params) - - return callback() - } - ) - } - - parallel(tasks, next) -} - -// ------------------------------ METHODS ------------------------------ - -function associate (models) { - this.belongsTo(models.Author, { - foreignKey: { - name: 'authorId', - allowNull: false - }, - onDelete: 'cascade' - }) - - this.belongsToMany(models.Tag, { - foreignKey: 'videoId', - through: models.VideoTag, - onDelete: 'cascade' - }) - - this.hasMany(models.VideoAbuse, { - foreignKey: { - name: 'videoId', - allowNull: false - }, - onDelete: 'cascade' - }) -} - -function generateMagnetUri () { - let baseUrlHttp, baseUrlWs - - if (this.isOwned()) { - baseUrlHttp = constants.CONFIG.WEBSERVER.URL - baseUrlWs = constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT - } else { - baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host - baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host - } - - const xs = baseUrlHttp + constants.STATIC_PATHS.TORRENTS + this.getTorrentName() - const announce = baseUrlWs + '/tracker/socket' - const urlList = [ baseUrlHttp + constants.STATIC_PATHS.WEBSEED + this.getVideoFilename() ] - - const magnetHash = { - xs, - announce, - urlList, - infoHash: this.infoHash, - name: this.name - } - - return magnetUtil.encode(magnetHash) -} - -function getVideoFilename () { - if (this.isOwned()) return this.id + this.extname - - return this.remoteId + this.extname -} - -function getThumbnailName () { - // We always have a copy of the thumbnail - return this.id + '.jpg' -} - -function getPreviewName () { - const extension = '.jpg' - - if (this.isOwned()) return this.id + extension - - return this.remoteId + extension -} - -function getTorrentName () { - const extension = '.torrent' - - if (this.isOwned()) return this.id + extension - - return this.remoteId + extension -} - -function isOwned () { - return this.remoteId === null -} - -function toFormatedJSON () { - let podHost - - if (this.Author.Pod) { - podHost = this.Author.Pod.host - } else { - // It means it's our video - podHost = constants.CONFIG.WEBSERVER.HOST - } - - // Maybe our pod is not up to date and there are new categories since our version - let categoryLabel = constants.VIDEO_CATEGORIES[this.category] - if (!categoryLabel) categoryLabel = 'Misc' - - // Maybe our pod is not up to date and there are new licences since our version - let licenceLabel = constants.VIDEO_LICENCES[this.licence] - if (!licenceLabel) licenceLabel = 'Unknown' - - // Language is an optional attribute - let languageLabel = constants.VIDEO_LANGUAGES[this.language] - if (!languageLabel) languageLabel = 'Unknown' - - const json = { - id: this.id, - name: this.name, - category: this.category, - categoryLabel, - licence: this.licence, - licenceLabel, - language: this.language, - languageLabel, - nsfw: this.nsfw, - description: this.description, - podHost, - isLocal: this.isOwned(), - magnetUri: this.generateMagnetUri(), - 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, - updatedAt: this.updatedAt - } - - return json -} - -function toAddRemoteJSON (callback) { - const self = this - - // Get thumbnail data to send to the other pod - const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - fs.readFile(thumbnailPath, function (err, thumbnailData) { - if (err) { - logger.error('Cannot read the thumbnail of the video') - return callback(err) - } - - const remoteVideo = { - name: self.name, - category: self.category, - licence: self.licence, - language: self.language, - nsfw: self.nsfw, - description: self.description, - infoHash: self.infoHash, - remoteId: self.id, - author: self.Author.name, - duration: self.duration, - thumbnailData: thumbnailData.toString('binary'), - tags: map(self.Tags, 'name'), - createdAt: self.createdAt, - updatedAt: self.updatedAt, - extname: self.extname, - views: self.views, - likes: self.likes, - dislikes: self.dislikes - } - - return callback(null, remoteVideo) - }) -} - -function toUpdateRemoteJSON (callback) { - const json = { - name: this.name, - category: this.category, - licence: this.licence, - language: this.language, - nsfw: this.nsfw, - description: this.description, - infoHash: this.infoHash, - remoteId: this.id, - author: this.Author.name, - duration: this.duration, - tags: map(this.Tags, 'name'), - createdAt: this.createdAt, - updatedAt: this.updatedAt, - extname: this.extname, - views: this.views, - likes: this.likes, - dislikes: this.dislikes - } - - return json -} - -function transcodeVideofile (finalCallback) { - const video = this - - const videosDirectory = constants.CONFIG.STORAGE.VIDEOS_DIR - const newExtname = '.mp4' - const videoInputPath = pathUtils.join(videosDirectory, video.getVideoFilename()) - const videoOutputPath = pathUtils.join(videosDirectory, video.id + '-transcoded' + newExtname) - - ffmpeg(videoInputPath) - .output(videoOutputPath) - .videoCodec('libx264') - .outputOption('-threads ' + constants.CONFIG.TRANSCODING.THREADS) - .outputOption('-movflags faststart') - .on('error', finalCallback) - .on('end', function () { - series([ - function removeOldFile (callback) { - fs.unlink(videoInputPath, callback) - }, - - function moveNewFile (callback) { - // Important to do this before getVideoFilename() to take in account the new file extension - video.set('extname', newExtname) - - const newVideoPath = pathUtils.join(videosDirectory, video.getVideoFilename()) - fs.rename(videoOutputPath, newVideoPath, callback) - }, - - function torrent (callback) { - const newVideoPath = pathUtils.join(videosDirectory, video.getVideoFilename()) - createTorrentFromVideo(video, newVideoPath, callback) - }, - - function videoExtension (callback) { - video.save().asCallback(callback) - } - - ], function (err) { - if (err) { - // Autodescruction... - video.destroy().asCallback(function (err) { - if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err }) - }) - - return finalCallback(err) - } - - return finalCallback(null) - }) - }) - .run() -} - -// ------------------------------ STATICS ------------------------------ - -function generateThumbnailFromData (video, thumbnailData, callback) { - // Creating the thumbnail for a remote video - - const thumbnailName = video.getThumbnailName() - const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName) - fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) { - if (err) return callback(err) - - return callback(null, thumbnailName) - }) -} - -function getDurationFromFile (videoPath, callback) { - ffmpeg.ffprobe(videoPath, function (err, metadata) { - if (err) return callback(err) - - return callback(null, Math.floor(metadata.format.duration)) - }) -} - -function list (callback) { - return this.findAll().asCallback(callback) -} - -function listForApi (start, count, sort, callback) { - // Exclude Blakclisted videos from the list - const query = { - offset: start, - limit: count, - distinct: true, // For the count, a video can have many tags - order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ], - include: [ - { - model: this.sequelize.models.Author, - include: [ { model: this.sequelize.models.Pod, required: false } ] - }, - - this.sequelize.models.Tag - ], - where: createBaseVideosWhere.call(this) - } - - return this.findAndCountAll(query).asCallback(function (err, result) { - if (err) return callback(err) - - return callback(null, result.rows, result.count) - }) -} - -function loadByHostAndRemoteId (fromHost, remoteId, callback) { - const query = { - where: { - remoteId: remoteId - }, - include: [ - { - model: this.sequelize.models.Author, - include: [ - { - model: this.sequelize.models.Pod, - required: true, - where: { - host: fromHost - } - } - ] - } - ] - } - - return this.findOne(query).asCallback(callback) -} - -function listOwnedAndPopulateAuthorAndTags (callback) { - // If remoteId is null this is *our* video - const query = { - where: { - remoteId: null - }, - include: [ this.sequelize.models.Author, this.sequelize.models.Tag ] - } - - return this.findAll(query).asCallback(callback) -} - -function listOwnedByAuthor (author, callback) { - const query = { - where: { - remoteId: null - }, - include: [ - { - model: this.sequelize.models.Author, - where: { - name: author - } - } - ] - } - - return this.findAll(query).asCallback(callback) -} - -function load (id, callback) { - return this.findById(id).asCallback(callback) -} - -function loadAndPopulateAuthor (id, callback) { - const options = { - include: [ this.sequelize.models.Author ] - } - - return this.findById(id, options).asCallback(callback) -} - -function loadAndPopulateAuthorAndPodAndTags (id, callback) { - const options = { - include: [ - { - model: this.sequelize.models.Author, - include: [ { model: this.sequelize.models.Pod, required: false } ] - }, - this.sequelize.models.Tag - ] - } - - return this.findById(id, options).asCallback(callback) -} - -function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) { - const podInclude = { - model: this.sequelize.models.Pod, - required: false - } - - const authorInclude = { - model: this.sequelize.models.Author, - include: [ - podInclude - ] - } - - const tagInclude = { - model: this.sequelize.models.Tag - } - - const query = { - where: createBaseVideosWhere.call(this), - offset: start, - limit: count, - distinct: true, // For the count, a video can have many tags - order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ] - } - - // Make an exact search with the magnet - if (field === 'magnetUri') { - const infoHash = magnetUtil.decode(value).infoHash - query.where.infoHash = infoHash - } else if (field === 'tags') { - const escapedValue = this.sequelize.escape('%' + value + '%') - query.where.id.$in = this.sequelize.literal( - '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')' - ) - } else if (field === 'host') { - // FIXME: Include our pod? (not stored in the database) - podInclude.where = { - host: { - $like: '%' + value + '%' - } - } - podInclude.required = true - } else if (field === 'author') { - authorInclude.where = { - name: { - $like: '%' + value + '%' - } - } - - // authorInclude.or = true - } else { - query.where[field] = { - $like: '%' + value + '%' - } - } - - query.include = [ - authorInclude, tagInclude - ] - - if (tagInclude.where) { - // query.include.push([ this.sequelize.models.Tag ]) - } - - return this.findAndCountAll(query).asCallback(function (err, result) { - if (err) return callback(err) - - return callback(null, result.rows, result.count) - }) -} - -// --------------------------------------------------------------------------- - -function createBaseVideosWhere () { - return { - id: { - $notIn: this.sequelize.literal( - '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")' - ) - } - } -} - -function removeThumbnail (video, callback) { - const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) - fs.unlink(thumbnailPath, callback) -} - -function removeFile (video, callback) { - const filePath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) - fs.unlink(filePath, callback) -} - -function removeTorrent (video, callback) { - const torrenPath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName()) - fs.unlink(torrenPath, callback) -} - -function removePreview (video, callback) { - // Same name than video thumnail - fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback) -} - -function createTorrentFromVideo (video, videoPath, callback) { - const options = { - announceList: [ - [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ] - ], - urlList: [ - constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename() - ] - } - - createTorrent(videoPath, options, function (err, torrent) { - if (err) return callback(err) - - const filePath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName()) - fs.writeFile(filePath, torrent, function (err) { - if (err) return callback(err) - - const parsedTorrent = parseTorrent(torrent) - video.set('infoHash', parsedTorrent.infoHash) - video.validate().asCallback(callback) - }) - }) -} - -function createPreview (video, videoPath, callback) { - generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback) -} - -function createThumbnail (video, videoPath, callback) { - generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback) -} - -function generateImage (video, videoPath, folder, imageName, size, callback) { - const options = { - filename: imageName, - count: 1, - folder - } - - if (!callback) { - callback = size - } else { - options.size = size - } - - ffmpeg(videoPath) - .on('error', callback) - .on('end', function () { - callback(null, imageName) - }) - .thumbnail(options) -} - -function removeFromBlacklist (video, callback) { - // Find the blacklisted video - db.BlacklistedVideo.loadByVideoId(video.id, function (err, video) { - // If an error occured, stop here - if (err) { - logger.error('Error when fetching video from blacklist.', { error: err }) - return callback(err) - } - - // If we found the video, remove it from the blacklist - if (video) { - video.destroy().asCallback(callback) - } else { - // If haven't found it, simply ignore it and do nothing - return callback() - } - }) -} diff --git a/server/models/video.ts b/server/models/video.ts new file mode 100644 index 000000000..1e29f1355 --- /dev/null +++ b/server/models/video.ts @@ -0,0 +1,873 @@ +import safeBuffer = require('safe-buffer') +const Buffer = safeBuffer.Buffer +import createTorrent = require('create-torrent') +import ffmpeg = require('fluent-ffmpeg') +import fs = require('fs') +import magnetUtil = require('magnet-uri') +import { map, values } from 'lodash' +import { parallel, series } from 'async' +import parseTorrent = require('parse-torrent') +import { join } from 'path' + +const db = require('../initializers/database') +import { + logger, + isVideoNameValid, + isVideoCategoryValid, + isVideoLicenceValid, + isVideoLanguageValid, + isVideoNSFWValid, + isVideoDescriptionValid, + isVideoInfoHashValid, + isVideoDurationValid +} from '../helpers' +import { + CONSTRAINTS_FIELDS, + CONFIG, + REMOTE_SCHEME, + STATIC_PATHS, + VIDEO_CATEGORIES, + VIDEO_LICENCES, + VIDEO_LANGUAGES, + THUMBNAILS_SIZE +} from '../initializers' +import { JobScheduler, removeVideoToFriends } from '../lib' +import { getSort } from './utils' + +// --------------------------------------------------------------------------- + +module.exports = function (sequelize, DataTypes) { + const Video = sequelize.define('Video', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + validate: { + isUUID: 4 + } + }, + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + nameValid: function (value) { + const res = isVideoNameValid(value) + if (res === false) throw new Error('Video name is not valid.') + } + } + }, + extname: { + type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)), + allowNull: false + }, + remoteId: { + type: DataTypes.UUID, + allowNull: true, + validate: { + isUUID: 4 + } + }, + category: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + categoryValid: function (value) { + const res = isVideoCategoryValid(value) + if (res === false) throw new Error('Video category is not valid.') + } + } + }, + licence: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: null, + validate: { + licenceValid: function (value) { + const res = isVideoLicenceValid(value) + if (res === false) throw new Error('Video licence is not valid.') + } + } + }, + language: { + type: DataTypes.INTEGER, + allowNull: true, + validate: { + languageValid: function (value) { + const res = isVideoLanguageValid(value) + if (res === false) throw new Error('Video language is not valid.') + } + } + }, + nsfw: { + type: DataTypes.BOOLEAN, + allowNull: false, + validate: { + nsfwValid: function (value) { + const res = isVideoNSFWValid(value) + if (res === false) throw new Error('Video nsfw attribute is not valid.') + } + } + }, + description: { + type: DataTypes.STRING, + allowNull: false, + validate: { + descriptionValid: function (value) { + const res = isVideoDescriptionValid(value) + if (res === false) throw new Error('Video description is not valid.') + } + } + }, + infoHash: { + type: DataTypes.STRING, + allowNull: false, + validate: { + infoHashValid: function (value) { + const res = isVideoInfoHashValid(value) + if (res === false) throw new Error('Video info hash is not valid.') + } + } + }, + duration: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + durationValid: function (value) { + const res = isVideoDurationValid(value) + if (res === false) throw new Error('Video duration is not valid.') + } + } + }, + views: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + validate: { + 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 + } + } + }, + { + indexes: [ + { + fields: [ 'authorId' ] + }, + { + fields: [ 'remoteId' ] + }, + { + fields: [ 'name' ] + }, + { + fields: [ 'createdAt' ] + }, + { + fields: [ 'duration' ] + }, + { + fields: [ 'infoHash' ] + }, + { + fields: [ 'views' ] + }, + { + fields: [ 'likes' ] + } + ], + classMethods: { + associate, + + generateThumbnailFromData, + getDurationFromFile, + list, + listForApi, + listOwnedAndPopulateAuthorAndTags, + listOwnedByAuthor, + load, + loadByHostAndRemoteId, + loadAndPopulateAuthor, + loadAndPopulateAuthorAndPodAndTags, + searchAndPopulateAuthorAndPodAndTags + }, + instanceMethods: { + generateMagnetUri, + getVideoFilename, + getThumbnailName, + getPreviewName, + getTorrentName, + isOwned, + toFormatedJSON, + toAddRemoteJSON, + toUpdateRemoteJSON, + transcodeVideofile, + removeFromBlacklist + }, + hooks: { + beforeValidate, + beforeCreate, + afterDestroy + } + } + ) + + return Video +} + +function beforeValidate (video, options, next) { + // Put a fake infoHash if it does not exists yet + if (video.isOwned() && !video.infoHash) { + // 40 hexa length + video.infoHash = '0123456789abcdef0123456789abcdef01234567' + } + + return next(null) +} + +function beforeCreate (video, options, next) { + const tasks = [] + + if (video.isOwned()) { + const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) + + tasks.push( + function createVideoTorrent (callback) { + createTorrentFromVideo(video, videoPath, callback) + }, + + function createVideoThumbnail (callback) { + createThumbnail(video, videoPath, callback) + }, + + function createVideoPreview (callback) { + createPreview(video, videoPath, callback) + } + ) + + if (CONFIG.TRANSCODING.ENABLED === true) { + tasks.push( + function createVideoTranscoderJob (callback) { + const dataInput = { + id: video.id + } + + JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput, callback) + } + ) + } + + return parallel(tasks, next) + } + + return next() +} + +function afterDestroy (video, options, next) { + const tasks = [] + + tasks.push( + function (callback) { + removeThumbnail(video, callback) + } + ) + + if (video.isOwned()) { + tasks.push( + function removeVideoFile (callback) { + removeFile(video, callback) + }, + + function removeVideoTorrent (callback) { + removeTorrent(video, callback) + }, + + function removeVideoPreview (callback) { + removePreview(video, callback) + }, + + function removeVideoToFriends (callback) { + const params = { + remoteId: video.id + } + + removeVideoToFriends(params) + + return callback() + } + ) + } + + parallel(tasks, next) +} + +// ------------------------------ METHODS ------------------------------ + +function associate (models) { + this.belongsTo(models.Author, { + foreignKey: { + name: 'authorId', + allowNull: false + }, + onDelete: 'cascade' + }) + + this.belongsToMany(models.Tag, { + foreignKey: 'videoId', + through: models.VideoTag, + onDelete: 'cascade' + }) + + this.hasMany(models.VideoAbuse, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) +} + +function generateMagnetUri () { + let baseUrlHttp + let baseUrlWs + + if (this.isOwned()) { + baseUrlHttp = CONFIG.WEBSERVER.URL + baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + } else { + baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host + baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host + } + + const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName() + const announce = baseUrlWs + '/tracker/socket' + const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ] + + const magnetHash = { + xs, + announce, + urlList, + infoHash: this.infoHash, + name: this.name + } + + return magnetUtil.encode(magnetHash) +} + +function getVideoFilename () { + if (this.isOwned()) return this.id + this.extname + + return this.remoteId + this.extname +} + +function getThumbnailName () { + // We always have a copy of the thumbnail + return this.id + '.jpg' +} + +function getPreviewName () { + const extension = '.jpg' + + if (this.isOwned()) return this.id + extension + + return this.remoteId + extension +} + +function getTorrentName () { + const extension = '.torrent' + + if (this.isOwned()) return this.id + extension + + return this.remoteId + extension +} + +function isOwned () { + return this.remoteId === null +} + +function toFormatedJSON () { + let podHost + + if (this.Author.Pod) { + podHost = this.Author.Pod.host + } else { + // It means it's our video + podHost = CONFIG.WEBSERVER.HOST + } + + // Maybe our pod is not up to date and there are new categories since our version + let categoryLabel = VIDEO_CATEGORIES[this.category] + if (!categoryLabel) categoryLabel = 'Misc' + + // Maybe our pod is not up to date and there are new licences since our version + let licenceLabel = VIDEO_LICENCES[this.licence] + if (!licenceLabel) licenceLabel = 'Unknown' + + // Language is an optional attribute + let languageLabel = VIDEO_LANGUAGES[this.language] + if (!languageLabel) languageLabel = 'Unknown' + + const json = { + id: this.id, + name: this.name, + category: this.category, + categoryLabel, + licence: this.licence, + licenceLabel, + language: this.language, + languageLabel, + nsfw: this.nsfw, + description: this.description, + podHost, + isLocal: this.isOwned(), + magnetUri: this.generateMagnetUri(), + author: this.Author.name, + duration: this.duration, + views: this.views, + likes: this.likes, + dislikes: this.dislikes, + tags: map(this.Tags, 'name'), + thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()), + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + + return json +} + +function toAddRemoteJSON (callback) { + const self = this + + // Get thumbnail data to send to the other pod + const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) + fs.readFile(thumbnailPath, function (err, thumbnailData) { + if (err) { + logger.error('Cannot read the thumbnail of the video') + return callback(err) + } + + const remoteVideo = { + name: self.name, + category: self.category, + licence: self.licence, + language: self.language, + nsfw: self.nsfw, + description: self.description, + infoHash: self.infoHash, + remoteId: self.id, + author: self.Author.name, + duration: self.duration, + thumbnailData: thumbnailData.toString('binary'), + tags: map(self.Tags, 'name'), + createdAt: self.createdAt, + updatedAt: self.updatedAt, + extname: self.extname, + views: self.views, + likes: self.likes, + dislikes: self.dislikes + } + + return callback(null, remoteVideo) + }) +} + +function toUpdateRemoteJSON (callback) { + const json = { + name: this.name, + category: this.category, + licence: this.licence, + language: this.language, + nsfw: this.nsfw, + description: this.description, + infoHash: this.infoHash, + remoteId: this.id, + author: this.Author.name, + duration: this.duration, + tags: map(this.Tags, 'name'), + createdAt: this.createdAt, + updatedAt: this.updatedAt, + extname: this.extname, + views: this.views, + likes: this.likes, + dislikes: this.dislikes + } + + return json +} + +function transcodeVideofile (finalCallback) { + const video = this + + const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR + const newExtname = '.mp4' + const videoInputPath = join(videosDirectory, video.getVideoFilename()) + const videoOutputPath = join(videosDirectory, video.id + '-transcoded' + newExtname) + + ffmpeg(videoInputPath) + .output(videoOutputPath) + .videoCodec('libx264') + .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) + .outputOption('-movflags faststart') + .on('error', finalCallback) + .on('end', function () { + series([ + function removeOldFile (callback) { + fs.unlink(videoInputPath, callback) + }, + + function moveNewFile (callback) { + // Important to do this before getVideoFilename() to take in account the new file extension + video.set('extname', newExtname) + + const newVideoPath = join(videosDirectory, video.getVideoFilename()) + fs.rename(videoOutputPath, newVideoPath, callback) + }, + + function torrent (callback) { + const newVideoPath = join(videosDirectory, video.getVideoFilename()) + createTorrentFromVideo(video, newVideoPath, callback) + }, + + function videoExtension (callback) { + video.save().asCallback(callback) + } + + ], function (err) { + if (err) { + // Autodescruction... + video.destroy().asCallback(function (err) { + if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err }) + }) + + return finalCallback(err) + } + + return finalCallback(null) + }) + }) + .run() +} + +// ------------------------------ STATICS ------------------------------ + +function generateThumbnailFromData (video, thumbnailData, callback) { + // Creating the thumbnail for a remote video + + const thumbnailName = video.getThumbnailName() + const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName) + fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) { + if (err) return callback(err) + + return callback(null, thumbnailName) + }) +} + +function getDurationFromFile (videoPath, callback) { + ffmpeg.ffprobe(videoPath, function (err, metadata) { + if (err) return callback(err) + + return callback(null, Math.floor(metadata.format.duration)) + }) +} + +function list (callback) { + return this.findAll().asCallback(callback) +} + +function listForApi (start, count, sort, callback) { + // Exclude Blakclisted videos from the list + const query = { + offset: start, + limit: count, + distinct: true, // For the count, a video can have many tags + order: [ getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ], + include: [ + { + model: this.sequelize.models.Author, + include: [ { model: this.sequelize.models.Pod, required: false } ] + }, + + this.sequelize.models.Tag + ], + where: createBaseVideosWhere.call(this) + } + + return this.findAndCountAll(query).asCallback(function (err, result) { + if (err) return callback(err) + + return callback(null, result.rows, result.count) + }) +} + +function loadByHostAndRemoteId (fromHost, remoteId, callback) { + const query = { + where: { + remoteId: remoteId + }, + include: [ + { + model: this.sequelize.models.Author, + include: [ + { + model: this.sequelize.models.Pod, + required: true, + where: { + host: fromHost + } + } + ] + } + ] + } + + return this.findOne(query).asCallback(callback) +} + +function listOwnedAndPopulateAuthorAndTags (callback) { + // If remoteId is null this is *our* video + const query = { + where: { + remoteId: null + }, + include: [ this.sequelize.models.Author, this.sequelize.models.Tag ] + } + + return this.findAll(query).asCallback(callback) +} + +function listOwnedByAuthor (author, callback) { + const query = { + where: { + remoteId: null + }, + include: [ + { + model: this.sequelize.models.Author, + where: { + name: author + } + } + ] + } + + return this.findAll(query).asCallback(callback) +} + +function load (id, callback) { + return this.findById(id).asCallback(callback) +} + +function loadAndPopulateAuthor (id, callback) { + const options = { + include: [ this.sequelize.models.Author ] + } + + return this.findById(id, options).asCallback(callback) +} + +function loadAndPopulateAuthorAndPodAndTags (id, callback) { + const options = { + include: [ + { + model: this.sequelize.models.Author, + include: [ { model: this.sequelize.models.Pod, required: false } ] + }, + this.sequelize.models.Tag + ] + } + + return this.findById(id, options).asCallback(callback) +} + +function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) { + const podInclude: any = { + model: this.sequelize.models.Pod, + required: false + } + + const authorInclude: any = { + model: this.sequelize.models.Author, + include: [ + podInclude + ] + } + + const tagInclude: any = { + model: this.sequelize.models.Tag + } + + const query: any = { + where: createBaseVideosWhere.call(this), + offset: start, + limit: count, + distinct: true, // For the count, a video can have many tags + order: [ getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ] + } + + // Make an exact search with the magnet + if (field === 'magnetUri') { + const infoHash = magnetUtil.decode(value).infoHash + query.where.infoHash = infoHash + } else if (field === 'tags') { + const escapedValue = this.sequelize.escape('%' + value + '%') + query.where.id.$in = this.sequelize.literal( + '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')' + ) + } else if (field === 'host') { + // FIXME: Include our pod? (not stored in the database) + podInclude.where = { + host: { + $like: '%' + value + '%' + } + } + podInclude.required = true + } else if (field === 'author') { + authorInclude.where = { + name: { + $like: '%' + value + '%' + } + } + + // authorInclude.or = true + } else { + query.where[field] = { + $like: '%' + value + '%' + } + } + + query.include = [ + authorInclude, tagInclude + ] + + if (tagInclude.where) { + // query.include.push([ this.sequelize.models.Tag ]) + } + + return this.findAndCountAll(query).asCallback(function (err, result) { + if (err) return callback(err) + + return callback(null, result.rows, result.count) + }) +} + +// --------------------------------------------------------------------------- + +function createBaseVideosWhere () { + return { + id: { + $notIn: this.sequelize.literal( + '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")' + ) + } + } +} + +function removeThumbnail (video, callback) { + const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) + fs.unlink(thumbnailPath, callback) +} + +function removeFile (video, callback) { + const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) + fs.unlink(filePath, callback) +} + +function removeTorrent (video, callback) { + const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName()) + fs.unlink(torrenPath, callback) +} + +function removePreview (video, callback) { + // Same name than video thumnail + fs.unlink(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback) +} + +function createTorrentFromVideo (video, videoPath, callback) { + const options = { + announceList: [ + [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ] + ], + urlList: [ + CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename() + ] + } + + createTorrent(videoPath, options, function (err, torrent) { + if (err) return callback(err) + + const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName()) + fs.writeFile(filePath, torrent, function (err) { + if (err) return callback(err) + + const parsedTorrent = parseTorrent(torrent) + video.set('infoHash', parsedTorrent.infoHash) + video.validate().asCallback(callback) + }) + }) +} + +function createPreview (video, videoPath, callback) { + generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback) +} + +function createThumbnail (video, videoPath, callback) { + generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE, callback) +} + +function generateImage (video, videoPath, folder, imageName, size, callback?) { + const options: any = { + filename: imageName, + count: 1, + folder + } + + if (!callback) { + callback = size + } else { + options.size = size + } + + ffmpeg(videoPath) + .on('error', callback) + .on('end', function () { + callback(null, imageName) + }) + .thumbnail(options) +} + +function removeFromBlacklist (video, callback) { + // Find the blacklisted video + db.BlacklistedVideo.loadByVideoId(video.id, function (err, video) { + // If an error occured, stop here + if (err) { + logger.error('Error when fetching video from blacklist.', { error: err }) + return callback(err) + } + + // If we found the video, remove it from the blacklist + if (video) { + video.destroy().asCallback(callback) + } else { + // If haven't found it, simply ignore it and do nothing + return callback() + } + }) +} -- cgit v1.2.3