1
0
Fork 0
peertube/server/models/video.js
Chocobozzz 9e167724f7 Server: make a basic "quick and dirty update" for videos
This system will be useful to to update some int video attributes
(likes, dislikes, views...)

The classic system is not used because we need some optimization for
scaling
2017-02-26 20:01:26 +01:00

660 lines
16 KiB
JavaScript

'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 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
// ---------------------------------------------------------------------------
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
}
},
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
}
}
},
{
indexes: [
{
fields: [ 'authorId' ]
},
{
fields: [ 'remoteId' ]
},
{
fields: [ 'name' ]
},
{
fields: [ 'createdAt' ]
},
{
fields: [ 'duration' ]
},
{
fields: [ 'infoHash' ]
},
{
fields: [ 'views' ]
}
],
classMethods: {
associate,
generateThumbnailFromData,
getDurationFromFile,
list,
listForApi,
listOwnedAndPopulateAuthorAndTags,
listOwnedByAuthor,
load,
loadByHostAndRemoteId,
loadAndPopulateAuthor,
loadAndPopulateAuthorAndPodAndTags,
searchAndPopulateAuthorAndPodAndTags
},
instanceMethods: {
generateMagnetUri,
getVideoFilename,
getThumbnailName,
getPreviewName,
getTorrentName,
isOwned,
toFormatedJSON,
toAddRemoteJSON,
toUpdateRemoteJSON
},
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) {
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 createVideoThumbnail (callback) {
createThumbnail(video, videoPath, callback)
},
function createVIdeoPreview (callback) {
createPreview(video, videoPath, 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
}
const json = {
id: this.id,
name: this.name,
description: this.description,
podHost,
isLocal: this.isOwned(),
magnetUri: this.generateMagnetUri(),
author: this.Author.name,
duration: this.duration,
views: this.views,
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,
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
}
return callback(null, remoteVideo)
})
}
function toUpdateRemoteJSON (callback) {
const json = {
name: this.name,
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
}
return json
}
// ------------------------------ 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) {
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
]
}
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: {},
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 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 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)
}