Add user history and resume videos

This commit is contained in:
Chocobozzz 2018-10-05 11:15:06 +02:00
parent a585824160
commit 6e46de095d
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
41 changed files with 649 additions and 122 deletions

View File

@ -2,9 +2,11 @@
[routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
class="video-thumbnail" class="video-thumbnail"
> >
<img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
<div class="video-thumbnail-overlay"> <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
{{ video.durationLabel }}
</div> <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
<div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
</div>
</a> </a>

View File

@ -29,6 +29,19 @@
} }
} }
.progress-bar {
height: 3px;
width: 100%;
position: relative;
top: -3px;
background-color: rgba(0, 0, 0, 0.20);
div {
height: 100%;
background-color: var(--mainColor);
}
}
.video-thumbnail-overlay { .video-thumbnail-overlay {
position: absolute; position: absolute;
right: 5px; right: 5px;

View File

@ -22,4 +22,12 @@ export class VideoThumbnailComponent {
return this.video.thumbnailUrl return this.video.thumbnailUrl
} }
getProgressPercent () {
if (!this.video.userHistory) return 0
const currentTime = this.video.userHistory.currentTime
return (currentTime / this.video.duration) * 100
}
} }

View File

@ -66,6 +66,10 @@ export class Video implements VideoServerModel {
avatar: Avatar avatar: Avatar
} }
userHistory?: {
currentTime: number
}
static buildClientUrl (videoUUID: string) { static buildClientUrl (videoUUID: string) {
return '/videos/watch/' + videoUUID return '/videos/watch/' + videoUUID
} }
@ -116,6 +120,8 @@ export class Video implements VideoServerModel {
this.blacklisted = hash.blacklisted this.blacklisted = hash.blacklisted
this.blacklistedReason = hash.blacklistedReason this.blacklistedReason = hash.blacklistedReason
this.userHistory = hash.userHistory
} }
isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {

View File

@ -58,6 +58,10 @@ export class VideoService implements VideosProvider {
return VideoService.BASE_VIDEO_URL + uuid + '/views' return VideoService.BASE_VIDEO_URL + uuid + '/views'
} }
getUserWatchingVideoUrl (uuid: string) {
return VideoService.BASE_VIDEO_URL + uuid + '/watching'
}
getVideo (uuid: string): Observable<VideoDetails> { getVideo (uuid: string): Observable<VideoDetails> {
return this.serverService.localeObservable return this.serverService.localeObservable
.pipe( .pipe(

View File

@ -369,7 +369,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
) )
} }
private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) { private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTimeFromUrl: number) {
this.video = video this.video = video
// Re init attributes // Re init attributes
@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.completeDescriptionShown = false this.completeDescriptionShown = false
this.remoteServerDown = false this.remoteServerDown = false
let startTime = startTimeFromUrl || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
// Don't start the video if we are at the end
if (this.video.duration - startTime <= 1) startTime = 0
if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
const res = await this.confirmService.confirm( const res = await this.confirmService.confirm(
this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
@ -414,7 +418,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
poster: this.video.previewUrl, poster: this.video.previewUrl,
startTime, startTime,
theaterMode: true, theaterMode: true,
language: this.localeId language: this.localeId,
userWatching: this.user ? {
url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
authorizationHeader: this.authService.getRequestHeaderValue()
} : undefined
}) })
if (this.videojsLocaleLoaded === false) { if (this.videojsLocaleLoaded === false) {

View File

@ -10,7 +10,7 @@ import './webtorrent-info-button'
import './peertube-videojs-plugin' import './peertube-videojs-plugin'
import './peertube-load-progress-bar' import './peertube-load-progress-bar'
import './theater-button' import './theater-button'
import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
@ -34,10 +34,13 @@ function getVideojsOptions (options: {
startTime: number | string startTime: number | string
theaterMode: boolean, theaterMode: boolean,
videoCaptions: VideoJSCaption[], videoCaptions: VideoJSCaption[],
language?: string, language?: string,
controls?: boolean, controls?: boolean,
muted?: boolean, muted?: boolean,
loop?: boolean loop?: boolean
userWatching?: UserWatching
}) { }) {
const videojsOptions = { const videojsOptions = {
// We don't use text track settings for now // We don't use text track settings for now
@ -57,7 +60,8 @@ function getVideojsOptions (options: {
playerElement: options.playerElement, playerElement: options.playerElement,
videoViewUrl: options.videoViewUrl, videoViewUrl: options.videoViewUrl,
videoDuration: options.videoDuration, videoDuration: options.videoDuration,
startTime: options.startTime startTime: options.startTime,
userWatching: options.userWatching
} }
}, },
controlBar: { controlBar: {

View File

@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent'
import { VideoFile } from '../../../../shared/models/videos/video.model' import { VideoFile } from '../../../../shared/models/videos/video.model'
import { renderVideo } from './video-renderer' import { renderVideo } from './video-renderer'
import './settings-menu-button' import './settings-menu-button'
import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
import * as CacheChunkStore from 'cache-chunk-store' import * as CacheChunkStore from 'cache-chunk-store'
import { PeertubeChunkStore } from './peertube-chunk-store' import { PeertubeChunkStore } from './peertube-chunk-store'
@ -32,7 +32,8 @@ class PeerTubePlugin extends Plugin {
AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth
USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
} }
private readonly webtorrent = new WebTorrent({ private readonly webtorrent = new WebTorrent({
@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin {
private videoViewInterval private videoViewInterval
private torrentInfoInterval private torrentInfoInterval
private autoQualityInterval private autoQualityInterval
private userWatchingVideoInterval
private addTorrentDelay private addTorrentDelay
private qualityObservationTimer private qualityObservationTimer
private runAutoQualitySchedulerTimer private runAutoQualitySchedulerTimer
@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin {
this.runTorrentInfoScheduler() this.runTorrentInfoScheduler()
this.runViewAdd() this.runViewAdd()
if (options.userWatching) this.runUserWatchVideo(options.userWatching)
this.player.one('play', () => { this.player.one('play', () => {
// Don't run immediately scheduler, wait some seconds the TCP connections are made // Don't run immediately scheduler, wait some seconds the TCP connections are made
this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin {
clearInterval(this.torrentInfoInterval) clearInterval(this.torrentInfoInterval)
clearInterval(this.autoQualityInterval) clearInterval(this.autoQualityInterval)
if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
// Don't need to destroy renderer, video player will be destroyed // Don't need to destroy renderer, video player will be destroyed
this.flushVideoFile(this.currentVideoFile, false) this.flushVideoFile(this.currentVideoFile, false)
@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin {
}, 1000) }, 1000)
} }
private runUserWatchVideo (options: UserWatching) {
let lastCurrentTime = 0
this.userWatchingVideoInterval = setInterval(() => {
const currentTime = Math.floor(this.player.currentTime())
if (currentTime - lastCurrentTime >= 1) {
lastCurrentTime = currentTime
this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
.catch(err => console.error('Cannot notify user is watching.', err))
}
}, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
}
private clearVideoViewInterval () { private clearVideoViewInterval () {
if (this.videoViewInterval !== undefined) { if (this.videoViewInterval !== undefined) {
clearInterval(this.videoViewInterval) clearInterval(this.videoViewInterval)
@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin {
return fetch(this.videoViewUrl, { method: 'POST' }) return fetch(this.videoViewUrl, { method: 'POST' })
} }
private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
const body = new URLSearchParams()
body.append('currentTime', currentTime.toString())
const headers = new Headers({ 'Authorization': authorizationHeader })
return fetch(url, { method: 'PUT', body, headers })
}
private fallbackToHttp (done?: Function, play = true) { private fallbackToHttp (done?: Function, play = true) {
this.disableAutoResolution(true) this.disableAutoResolution(true)

View File

@ -22,6 +22,11 @@ type VideoJSCaption = {
src: string src: string
} }
type UserWatching = {
url: string,
authorizationHeader: string
}
type PeertubePluginOptions = { type PeertubePluginOptions = {
videoFiles: VideoFile[] videoFiles: VideoFile[]
playerElement: HTMLVideoElement playerElement: HTMLVideoElement
@ -30,6 +35,8 @@ type PeertubePluginOptions = {
startTime: number | string startTime: number | string
autoplay: boolean, autoplay: boolean,
videoCaptions: VideoJSCaption[] videoCaptions: VideoJSCaption[]
userWatching?: UserWatching
} }
// videojs typings don't have some method we need // videojs typings don't have some method we need
@ -39,5 +46,6 @@ export {
VideoJSComponentInterface, VideoJSComponentInterface,
PeertubePluginOptions, PeertubePluginOptions,
videojsUntyped, videojsUntyped,
VideoJSCaption VideoJSCaption,
UserWatching
} }

View File

@ -13,8 +13,7 @@ import {
localVideoChannelValidator, localVideoChannelValidator,
videosCustomGetValidator videosCustomGetValidator
} from '../../middlewares' } from '../../middlewares'
import { videosGetValidator, videosShareValidator } from '../../middlewares/validators' import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators'
import { videoCommentGetValidator } from '../../middlewares/validators/video-comments'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
import { ActorModel } from '../../models/activitypub/actor' import { ActorModel } from '../../models/activitypub/actor'
import { ActorFollowModel } from '../../models/activitypub/actor-follow' import { ActorFollowModel } from '../../models/activitypub/actor-follow'

View File

@ -117,7 +117,8 @@ function searchVideos (req: express.Request, res: express.Response) {
async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
const options = Object.assign(query, { const options = Object.assign(query, {
includeLocalVideos: true, includeLocalVideos: true,
nsfw: buildNSFWFilter(res, query.nsfw) nsfw: buildNSFWFilter(res, query.nsfw),
userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
}) })
const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) const resultList = await VideoModel.searchAndPopulateAccountAndServer(options)

View File

@ -1,10 +1,6 @@
import * as express from 'express' import * as express from 'express'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
import { import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
addVideoCaptionValidator,
deleteVideoCaptionValidator,
listVideoCaptionsValidator
} from '../../../middlewares/validators/video-captions'
import { createReqFiles } from '../../../helpers/express-utils' import { createReqFiles } from '../../../helpers/express-utils'
import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'

View File

@ -13,14 +13,14 @@ import {
setDefaultPagination, setDefaultPagination,
setDefaultSort setDefaultSort
} from '../../../middlewares' } from '../../../middlewares'
import { videoCommentThreadsSortValidator } from '../../../middlewares/validators'
import { import {
addVideoCommentReplyValidator, addVideoCommentReplyValidator,
addVideoCommentThreadValidator, addVideoCommentThreadValidator,
listVideoCommentThreadsValidator, listVideoCommentThreadsValidator,
listVideoThreadCommentsValidator, listVideoThreadCommentsValidator,
removeVideoCommentValidator removeVideoCommentValidator,
} from '../../../middlewares/validators/video-comments' videoCommentThreadsSortValidator
} from '../../../middlewares/validators'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoCommentModel } from '../../../models/video/video-comment'
import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'

View File

@ -57,6 +57,7 @@ import { videoCaptionsRouter } from './captions'
import { videoImportsRouter } from './import' import { videoImportsRouter } from './import'
import { resetSequelizeInstance } from '../../../helpers/database-utils' import { resetSequelizeInstance } from '../../../helpers/database-utils'
import { rename } from 'fs-extra' import { rename } from 'fs-extra'
import { watchingRouter } from './watching'
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router() const videosRouter = express.Router()
@ -86,6 +87,7 @@ videosRouter.use('/', videoCommentRouter)
videosRouter.use('/', videoCaptionsRouter) videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter) videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter) videosRouter.use('/', ownershipVideoRouter)
videosRouter.use('/', watchingRouter)
videosRouter.get('/categories', listVideoCategories) videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences) videosRouter.get('/licences', listVideoLicences)
@ -119,6 +121,7 @@ videosRouter.get('/:id/description',
asyncMiddleware(getVideoDescription) asyncMiddleware(getVideoDescription)
) )
videosRouter.get('/:id', videosRouter.get('/:id',
optionalAuthenticate,
asyncMiddleware(videosGetValidator), asyncMiddleware(videosGetValidator),
getVideo getVideo
) )
@ -433,7 +436,8 @@ async function listVideos (req: express.Request, res: express.Response, next: ex
tagsAllOf: req.query.tagsAllOf, tagsAllOf: req.query.tagsAllOf,
nsfw: buildNSFWFilter(res, req.query.nsfw), nsfw: buildNSFWFilter(res, req.query.nsfw),
filter: req.query.filter as VideoFilter, filter: req.query.filter as VideoFilter,
withFiles: false withFiles: false,
userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
}) })
return res.json(getFormattedObjects(resultList.data, resultList.total)) return res.json(getFormattedObjects(resultList.data, resultList.total))

View File

@ -0,0 +1,36 @@
import * as express from 'express'
import { UserWatchingVideo } from '../../../../shared'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares'
import { UserVideoHistoryModel } from '../../../models/account/user-video-history'
import { UserModel } from '../../../models/account/user'
const watchingRouter = express.Router()
watchingRouter.put('/:videoId/watching',
authenticate,
asyncMiddleware(videoWatchingValidator),
asyncRetryTransactionMiddleware(userWatchVideo)
)
// ---------------------------------------------------------------------------
export {
watchingRouter
}
// ---------------------------------------------------------------------------
async function userWatchVideo (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User as UserModel
const body: UserWatchingVideo = req.body
const { id: videoId } = res.locals.video as { id: number }
await UserVideoHistoryModel.upsert({
videoId,
userId: user.id,
currentTime: body.currentTime
})
return res.type('json').status(204).end()
}

View File

@ -154,7 +154,9 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use
} }
async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
const video = await fetchVideo(id, fetchType) const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
const video = await fetchVideo(id, fetchType, userId)
if (video === null) { if (video === null) {
res.status(404) res.status(404)

View File

@ -2,8 +2,8 @@ import { VideoModel } from '../models/video/video'
type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' type VideoFetchType = 'all' | 'only-video' | 'id' | 'none'
function fetchVideo (id: number | string, fetchType: VideoFetchType) { function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) {
if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id) if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
if (fetchType === 'only-video') return VideoModel.load(id) if (fetchType === 'only-video') return VideoModel.load(id)

View File

@ -28,6 +28,7 @@ import { VideoImportModel } from '../models/video/video-import'
import { VideoViewModel } from '../models/video/video-views' import { VideoViewModel } from '../models/video/video-views'
import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
import { UserVideoHistoryModel } from '../models/account/user-video-history'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -89,7 +90,8 @@ async function initDatabaseModels (silent: boolean) {
ScheduleVideoUpdateModel, ScheduleVideoUpdateModel,
VideoImportModel, VideoImportModel,
VideoViewModel, VideoViewModel,
VideoRedundancyModel VideoRedundancyModel,
UserVideoHistoryModel
]) ])
// Check extensions exist in the database // Check extensions exist in the database

View File

@ -48,6 +48,8 @@ class Redis {
) )
} }
/************* Forgot password *************/
async setResetPasswordVerificationString (userId: number) { async setResetPasswordVerificationString (userId: number) {
const generatedString = await generateRandomString(32) const generatedString = await generateRandomString(32)
@ -60,6 +62,8 @@ class Redis {
return this.getValue(this.generateResetPasswordKey(userId)) return this.getValue(this.generateResetPasswordKey(userId))
} }
/************* Email verification *************/
async setVerifyEmailVerificationString (userId: number) { async setVerifyEmailVerificationString (userId: number) {
const generatedString = await generateRandomString(32) const generatedString = await generateRandomString(32)
@ -72,16 +76,20 @@ class Redis {
return this.getValue(this.generateVerifyEmailKey(userId)) return this.getValue(this.generateVerifyEmailKey(userId))
} }
/************* Views per IP *************/
setIPVideoView (ip: string, videoUUID: string) { setIPVideoView (ip: string, videoUUID: string) {
return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) return this.setValue(this.generateViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME)
} }
async isVideoIPViewExists (ip: string, videoUUID: string) { async isVideoIPViewExists (ip: string, videoUUID: string) {
return this.exists(this.buildViewKey(ip, videoUUID)) return this.exists(this.generateViewKey(ip, videoUUID))
} }
/************* API cache *************/
async getCachedRoute (req: express.Request) { async getCachedRoute (req: express.Request) {
const cached = await this.getObject(this.buildCachedRouteKey(req)) const cached = await this.getObject(this.generateCachedRouteKey(req))
return cached as CachedRoute return cached as CachedRoute
} }
@ -94,9 +102,11 @@ class Redis {
(statusCode) ? { statusCode: statusCode.toString() } : null (statusCode) ? { statusCode: statusCode.toString() } : null
) )
return this.setObject(this.buildCachedRouteKey(req), cached, lifetime) return this.setObject(this.generateCachedRouteKey(req), cached, lifetime)
} }
/************* Video views *************/
addVideoView (videoId: number) { addVideoView (videoId: number) {
const keyIncr = this.generateVideoViewKey(videoId) const keyIncr = this.generateVideoViewKey(videoId)
const keySet = this.generateVideosViewKey() const keySet = this.generateVideosViewKey()
@ -131,33 +141,37 @@ class Redis {
]) ])
} }
generateVideosViewKey (hour?: number) { /************* Keys generation *************/
generateCachedRouteKey (req: express.Request) {
return req.method + '-' + req.originalUrl
}
private generateVideosViewKey (hour?: number) {
if (!hour) hour = new Date().getHours() if (!hour) hour = new Date().getHours()
return `videos-view-h${hour}` return `videos-view-h${hour}`
} }
generateVideoViewKey (videoId: number, hour?: number) { private generateVideoViewKey (videoId: number, hour?: number) {
if (!hour) hour = new Date().getHours() if (!hour) hour = new Date().getHours()
return `video-view-${videoId}-h${hour}` return `video-view-${videoId}-h${hour}`
} }
generateResetPasswordKey (userId: number) { private generateResetPasswordKey (userId: number) {
return 'reset-password-' + userId return 'reset-password-' + userId
} }
generateVerifyEmailKey (userId: number) { private generateVerifyEmailKey (userId: number) {
return 'verify-email-' + userId return 'verify-email-' + userId
} }
buildViewKey (ip: string, videoUUID: string) { private generateViewKey (ip: string, videoUUID: string) {
return videoUUID + '-' + ip return videoUUID + '-' + ip
} }
buildCachedRouteKey (req: express.Request) { /************* Redis helpers *************/
return req.method + '-' + req.originalUrl
}
private getValue (key: string) { private getValue (key: string) {
return new Promise<string>((res, rej) => { return new Promise<string>((res, rej) => {
@ -197,6 +211,12 @@ class Redis {
}) })
} }
private deleteFieldInHash (key: string, field: string) {
return new Promise<void>((res, rej) => {
this.client.hdel(this.prefix + key, field, err => err ? rej(err) : res())
})
}
private setValue (key: string, value: string, expirationMilliseconds: number) { private setValue (key: string, value: string, expirationMilliseconds: number) {
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => {
@ -235,6 +255,16 @@ class Redis {
}) })
} }
private setValueInHash (key: string, field: string, value: string) {
return new Promise<void>((res, rej) => {
this.client.hset(this.prefix + key, field, value, (err) => {
if (err) return rej(err)
return res()
})
})
}
private increment (key: string) { private increment (key: string) {
return new Promise<number>((res, rej) => { return new Promise<number>((res, rej) => {
this.client.incr(this.prefix + key, (err, value) => { this.client.incr(this.prefix + key, (err, value) => {

View File

@ -8,7 +8,7 @@ const lock = new AsyncLock({ timeout: 5000 })
function cacheRoute (lifetimeArg: string | number) { function cacheRoute (lifetimeArg: string | number) {
return async function (req: express.Request, res: express.Response, next: express.NextFunction) { return async function (req: express.Request, res: express.Response, next: express.NextFunction) {
const redisKey = Redis.Instance.buildCachedRouteKey(req) const redisKey = Redis.Instance.generateCachedRouteKey(req)
try { try {
await lock.acquire(redisKey, async (done) => { await lock.acquire(redisKey, async (done) => {

View File

@ -8,9 +8,5 @@ export * from './sort'
export * from './users' export * from './users'
export * from './user-subscriptions' export * from './user-subscriptions'
export * from './videos' export * from './videos'
export * from './video-abuses'
export * from './video-blacklist'
export * from './video-channels'
export * from './webfinger' export * from './webfinger'
export * from './search' export * from './search'
export * from './video-imports'

View File

@ -0,0 +1,8 @@
export * from './video-abuses'
export * from './video-blacklist'
export * from './video-captions'
export * from './video-channels'
export * from './video-comments'
export * from './video-imports'
export * from './video-watch'
export * from './videos'

View File

@ -1,16 +1,16 @@
import * as express from 'express' import * as express from 'express'
import 'express-validator' import 'express-validator'
import { body, param } from 'express-validator/check' import { body, param } from 'express-validator/check'
import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
import { isVideoExist } from '../../helpers/custom-validators/videos' import { isVideoExist } from '../../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
import { import {
isVideoAbuseExist, isVideoAbuseExist,
isVideoAbuseModerationCommentValid, isVideoAbuseModerationCommentValid,
isVideoAbuseReasonValid, isVideoAbuseReasonValid,
isVideoAbuseStateValid isVideoAbuseStateValid
} from '../../helpers/custom-validators/video-abuses' } from '../../../helpers/custom-validators/video-abuses'
const videoAbuseReportValidator = [ const videoAbuseReportValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),

View File

@ -1,10 +1,10 @@
import * as express from 'express' import * as express from 'express'
import { body, param } from 'express-validator/check' import { body, param } from 'express-validator/check'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
import { isVideoExist } from '../../helpers/custom-validators/videos' import { isVideoExist } from '../../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist'
const videosBlacklistRemoveValidator = [ const videosBlacklistRemoveValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),

View File

@ -1,13 +1,13 @@
import * as express from 'express' import * as express from 'express'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos' import { checkUserCanManageVideo, isVideoExist } from '../../../helpers/custom-validators/videos'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
import { body, param } from 'express-validator/check' import { body, param } from 'express-validator/check'
import { CONSTRAINTS_FIELDS } from '../../initializers' import { CONSTRAINTS_FIELDS } from '../../../initializers'
import { UserRight } from '../../../shared' import { UserRight } from '../../../../shared'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
import { cleanUpReqFiles } from '../../helpers/express-utils' import { cleanUpReqFiles } from '../../../helpers/express-utils'
const addVideoCaptionValidator = [ const addVideoCaptionValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),

View File

@ -1,20 +1,20 @@
import * as express from 'express' import * as express from 'express'
import { body, param } from 'express-validator/check' import { body, param } from 'express-validator/check'
import { UserRight } from '../../../shared' import { UserRight } from '../../../../shared'
import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' import { isAccountNameWithHostExist } from '../../../helpers/custom-validators/accounts'
import { import {
isLocalVideoChannelNameExist, isLocalVideoChannelNameExist,
isVideoChannelDescriptionValid, isVideoChannelDescriptionValid,
isVideoChannelNameValid, isVideoChannelNameValid,
isVideoChannelNameWithHostExist, isVideoChannelNameWithHostExist,
isVideoChannelSupportValid isVideoChannelSupportValid
} from '../../helpers/custom-validators/video-channels' } from '../../../helpers/custom-validators/video-channels'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { UserModel } from '../../models/account/user' import { UserModel } from '../../../models/account/user'
import { VideoChannelModel } from '../../models/video/video-channel' import { VideoChannelModel } from '../../../models/video/video-channel'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
import { ActorModel } from '../../models/activitypub/actor' import { ActorModel } from '../../../models/activitypub/actor'
const listVideoAccountChannelsValidator = [ const listVideoAccountChannelsValidator = [
param('accountName').exists().withMessage('Should have a valid account name'), param('accountName').exists().withMessage('Should have a valid account name'),

View File

@ -1,14 +1,14 @@
import * as express from 'express' import * as express from 'express'
import { body, param } from 'express-validator/check' import { body, param } from 'express-validator/check'
import { UserRight } from '../../../shared' import { UserRight } from '../../../../shared'
import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments'
import { isVideoExist } from '../../helpers/custom-validators/videos' import { isVideoExist } from '../../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { UserModel } from '../../models/account/user' import { UserModel } from '../../../models/account/user'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoCommentModel } from '../../models/video/video-comment' import { VideoCommentModel } from '../../../models/video/video-comment'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
const listVideoCommentThreadsValidator = [ const listVideoCommentThreadsValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),

View File

@ -1,14 +1,14 @@
import * as express from 'express' import * as express from 'express'
import { body } from 'express-validator/check' import { body } from 'express-validator/check'
import { isIdValid } from '../../helpers/custom-validators/misc' import { isIdValid } from '../../../helpers/custom-validators/misc'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
import { getCommonVideoAttributes } from './videos' import { getCommonVideoAttributes } from './videos'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../helpers/custom-validators/video-imports' import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
import { cleanUpReqFiles } from '../../helpers/express-utils' import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos' import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
import { CONFIG } from '../../initializers/constants' import { CONFIG } from '../../../initializers/constants'
import { CONSTRAINTS_FIELDS } from '../../initializers' import { CONSTRAINTS_FIELDS } from '../../../initializers'
const videoImportAddValidator = getCommonVideoAttributes().concat([ const videoImportAddValidator = getCommonVideoAttributes().concat([
body('channelId') body('channelId')

View File

@ -0,0 +1,28 @@
import { body, param } from 'express-validator/check'
import * as express from 'express'
import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
import { isVideoExist } from '../../../helpers/custom-validators/videos'
import { areValidationErrors } from '../utils'
import { logger } from '../../../helpers/logger'
const videoWatchingValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('currentTime')
.toInt()
.isInt().withMessage('Should have correct current time'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoWatching parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.videoId, res, 'id')) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoWatchingValidator
}

View File

@ -1,7 +1,7 @@
import * as express from 'express' import * as express from 'express'
import 'express-validator' import 'express-validator'
import { body, param, ValidationChain } from 'express-validator/check' import { body, param, ValidationChain } from 'express-validator/check'
import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../shared' import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
import { import {
isBooleanValid, isBooleanValid,
isDateValid, isDateValid,
@ -10,7 +10,7 @@ import {
isUUIDValid, isUUIDValid,
toIntOrNull, toIntOrNull,
toValueOrNull toValueOrNull
} from '../../helpers/custom-validators/misc' } from '../../../helpers/custom-validators/misc'
import { import {
checkUserCanManageVideo, checkUserCanManageVideo,
isScheduleVideoUpdatePrivacyValid, isScheduleVideoUpdatePrivacyValid,
@ -27,21 +27,21 @@ import {
isVideoRatingTypeValid, isVideoRatingTypeValid,
isVideoSupportValid, isVideoSupportValid,
isVideoTagsValid isVideoTagsValid
} from '../../helpers/custom-validators/videos' } from '../../../helpers/custom-validators/videos'
import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
import { logger } from '../../helpers/logger' import { logger } from '../../../helpers/logger'
import { CONSTRAINTS_FIELDS } from '../../initializers' import { CONSTRAINTS_FIELDS } from '../../../initializers'
import { VideoShareModel } from '../../models/video/video-share' import { VideoShareModel } from '../../../models/video/video-share'
import { authenticate } from '../oauth' import { authenticate } from '../../oauth'
import { areValidationErrors } from './utils' import { areValidationErrors } from '../utils'
import { cleanUpReqFiles } from '../../helpers/express-utils' import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { UserModel } from '../../models/account/user' import { UserModel } from '../../../models/account/user'
import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../helpers/custom-validators/video-ownership' import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model' import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../../models/account/account'
import { VideoFetchType } from '../../helpers/video' import { VideoFetchType } from '../../../helpers/video'
const videosAddValidator = getCommonVideoAttributes().concat([ const videosAddValidator = getCommonVideoAttributes().concat([
body('videofile') body('videofile')

View File

@ -0,0 +1,55 @@
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { VideoModel } from '../video/video'
import { UserModel } from './user'
@Table({
tableName: 'userVideoHistory',
indexes: [
{
fields: [ 'userId', 'videoId' ],
unique: true
},
{
fields: [ 'userId' ]
},
{
fields: [ 'videoId' ]
}
]
})
export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
@AllowNull(false)
@IsInt
@Column
currentTime: number
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
Video: VideoModel
@ForeignKey(() => UserModel)
@Column
userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
User: UserModel
}

View File

@ -10,6 +10,7 @@ import {
getVideoLikesActivityPubUrl, getVideoLikesActivityPubUrl,
getVideoSharesActivityPubUrl getVideoSharesActivityPubUrl
} from '../../lib/activitypub' } from '../../lib/activitypub'
import { isArray } from 'util'
export type VideoFormattingJSONOptions = { export type VideoFormattingJSONOptions = {
completeDescription?: boolean completeDescription?: boolean
@ -24,6 +25,8 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
const formattedAccount = video.VideoChannel.Account.toFormattedJSON() const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
const formattedVideoChannel = video.VideoChannel.toFormattedJSON() const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
const videoObject: Video = { const videoObject: Video = {
id: video.id, id: video.id,
uuid: video.uuid, uuid: video.uuid,
@ -74,7 +77,11 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
url: formattedVideoChannel.url, url: formattedVideoChannel.url,
host: formattedVideoChannel.host, host: formattedVideoChannel.host,
avatar: formattedVideoChannel.avatar avatar: formattedVideoChannel.avatar
} },
userHistory: userHistory ? {
currentTime: userHistory.currentTime
} : undefined
} }
if (options) { if (options) {

View File

@ -92,6 +92,8 @@ import {
videoModelToFormattedJSON videoModelToFormattedJSON
} from './video-format-utils' } from './video-format-utils'
import * as validator from 'validator' import * as validator from 'validator'
import { UserVideoHistoryModel } from '../account/user-video-history'
// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
const indexes: Sequelize.DefineIndexesOptions[] = [ const indexes: Sequelize.DefineIndexesOptions[] = [
@ -127,7 +129,8 @@ export enum ScopeNames {
WITH_TAGS = 'WITH_TAGS', WITH_TAGS = 'WITH_TAGS',
WITH_FILES = 'WITH_FILES', WITH_FILES = 'WITH_FILES',
WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
WITH_BLACKLISTED = 'WITH_BLACKLISTED' WITH_BLACKLISTED = 'WITH_BLACKLISTED',
WITH_USER_HISTORY = 'WITH_USER_HISTORY'
} }
type ForAPIOptions = { type ForAPIOptions = {
@ -464,6 +467,8 @@ type AvailableForListIDsOptions = {
include: [ include: [
{ {
model: () => VideoFileModel.unscoped(), model: () => VideoFileModel.unscoped(),
// FIXME: typings
[ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
required: false, required: false,
include: [ include: [
{ {
@ -482,6 +487,20 @@ type AvailableForListIDsOptions = {
required: false required: false
} }
] ]
},
[ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => {
return {
include: [
{
attributes: [ 'currentTime' ],
model: UserVideoHistoryModel.unscoped(),
required: false,
where: {
userId
}
}
]
}
} }
}) })
@Table({ @Table({
@ -672,11 +691,19 @@ export class VideoModel extends Model<VideoModel> {
name: 'videoId', name: 'videoId',
allowNull: false allowNull: false
}, },
onDelete: 'cascade', onDelete: 'cascade'
hooks: true
}) })
VideoViews: VideoViewModel[] VideoViews: VideoViewModel[]
@HasMany(() => UserVideoHistoryModel, {
foreignKey: {
name: 'videoId',
allowNull: false
},
onDelete: 'cascade'
})
UserVideoHistories: UserVideoHistoryModel[]
@HasOne(() => ScheduleVideoUpdateModel, { @HasOne(() => ScheduleVideoUpdateModel, {
foreignKey: { foreignKey: {
name: 'videoId', name: 'videoId',
@ -930,7 +957,8 @@ export class VideoModel extends Model<VideoModel> {
accountId?: number, accountId?: number,
videoChannelId?: number, videoChannelId?: number,
actorId?: number actorId?: number
trendingDays?: number trendingDays?: number,
userId?: number
}, countVideos = true) { }, countVideos = true) {
const query: IFindOptions<VideoModel> = { const query: IFindOptions<VideoModel> = {
offset: options.start, offset: options.start,
@ -961,6 +989,7 @@ export class VideoModel extends Model<VideoModel> {
accountId: options.accountId, accountId: options.accountId,
videoChannelId: options.videoChannelId, videoChannelId: options.videoChannelId,
includeLocalVideos: options.includeLocalVideos, includeLocalVideos: options.includeLocalVideos,
userId: options.userId,
trendingDays trendingDays
} }
@ -983,6 +1012,7 @@ export class VideoModel extends Model<VideoModel> {
tagsAllOf?: string[] tagsAllOf?: string[]
durationMin?: number // seconds durationMin?: number // seconds
durationMax?: number // seconds durationMax?: number // seconds
userId?: number
}) { }) {
const whereAnd = [] const whereAnd = []
@ -1058,7 +1088,8 @@ export class VideoModel extends Model<VideoModel> {
licenceOneOf: options.licenceOneOf, licenceOneOf: options.licenceOneOf,
languageOneOf: options.languageOneOf, languageOneOf: options.languageOneOf,
tagsOneOf: options.tagsOneOf, tagsOneOf: options.tagsOneOf,
tagsAllOf: options.tagsAllOf tagsAllOf: options.tagsAllOf,
userId: options.userId
} }
return VideoModel.getAvailableForApi(query, queryOptions) return VideoModel.getAvailableForApi(query, queryOptions)
@ -1125,7 +1156,7 @@ export class VideoModel extends Model<VideoModel> {
return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
} }
static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) { static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
const where = VideoModel.buildWhereIdOrUUID(id) const where = VideoModel.buildWhereIdOrUUID(id)
const options = { const options = {
@ -1134,14 +1165,20 @@ export class VideoModel extends Model<VideoModel> {
transaction: t transaction: t
} }
const scopes = [
ScopeNames.WITH_TAGS,
ScopeNames.WITH_BLACKLISTED,
ScopeNames.WITH_FILES,
ScopeNames.WITH_ACCOUNT_DETAILS,
ScopeNames.WITH_SCHEDULED_UPDATE
]
if (userId) {
scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
}
return VideoModel return VideoModel
.scope([ .scope(scopes)
ScopeNames.WITH_TAGS,
ScopeNames.WITH_BLACKLISTED,
ScopeNames.WITH_FILES,
ScopeNames.WITH_ACCOUNT_DETAILS,
ScopeNames.WITH_SCHEDULED_UPDATE
])
.findOne(options) .findOne(options)
} }
@ -1225,7 +1262,11 @@ export class VideoModel extends Model<VideoModel> {
return {} return {}
} }
private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) { private static async getAvailableForApi (
query: IFindOptions<VideoModel>,
options: AvailableForListIDsOptions & { userId?: number},
countVideos = true
) {
const idsScope = { const idsScope = {
method: [ method: [
ScopeNames.AVAILABLE_FOR_LIST_IDS, options ScopeNames.AVAILABLE_FOR_LIST_IDS, options
@ -1249,8 +1290,15 @@ export class VideoModel extends Model<VideoModel> {
if (ids.length === 0) return { data: [], total: count } if (ids.length === 0) return { data: [], total: count }
const apiScope = { // FIXME: typings
method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] const apiScope: any[] = [
{
method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ]
}
]
if (options.userId) {
apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] })
} }
const secondQuery = { const secondQuery = {

View File

@ -15,3 +15,4 @@ import './video-channels'
import './video-comments' import './video-comments'
import './video-imports' import './video-imports'
import './videos' import './videos'
import './videos-history'

View File

@ -0,0 +1,79 @@
/* tslint:disable:no-unused-expression */
import * as chai from 'chai'
import 'mocha'
import {
flushTests,
killallServers,
makePostBodyRequest,
makePutBodyRequest,
runServer,
ServerInfo,
setAccessTokensToServers,
uploadVideo
} from '../../utils'
const expect = chai.expect
describe('Test videos history API validator', function () {
let path: string
let server: ServerInfo
// ---------------------------------------------------------------
before(async function () {
this.timeout(30000)
await flushTests()
server = await runServer(1)
await setAccessTokensToServers([ server ])
const res = await uploadVideo(server.url, server.accessToken, {})
const videoUUID = res.body.video.uuid
path = '/api/v1/videos/' + videoUUID + '/watching'
})
describe('When notifying a user is watching a video', function () {
it('Should fail with an unauthenticated user', async function () {
const fields = { currentTime: 5 }
await makePutBodyRequest({ url: server.url, path, fields, statusCodeExpected: 401 })
})
it('Should fail with an incorrect video id', async function () {
const fields = { currentTime: 5 }
const path = '/api/v1/videos/blabla/watching'
await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 })
})
it('Should fail with an unknown video', async function () {
const fields = { currentTime: 5 }
const path = '/api/v1/videos/d91fff41-c24d-4508-8e13-3bd5902c3b02/watching'
await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 404 })
})
it('Should fail with a bad current time', async function () {
const fields = { currentTime: 'hello' }
await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 })
})
it('Should succeed with the correct parameters', async function () {
const fields = { currentTime: 5 }
await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 204 })
})
})
after(async function () {
killallServers([ server ])
// Keep the logs if the test failed
if (this['ok']) {
await flushTests()
}
})
})

View File

@ -14,4 +14,5 @@ import './video-nsfw'
import './video-privacy' import './video-privacy'
import './video-schedule-update' import './video-schedule-update'
import './video-transcoder' import './video-transcoder'
import './videos-history'
import './videos-overview' import './videos-overview'

View File

@ -0,0 +1,128 @@
/* tslint:disable:no-unused-expression */
import * as chai from 'chai'
import 'mocha'
import {
flushTests,
getVideosListWithToken,
getVideoWithToken,
killallServers, makePutBodyRequest,
runServer, searchVideoWithToken,
ServerInfo,
setAccessTokensToServers,
uploadVideo
} from '../../utils'
import { Video, VideoDetails } from '../../../../shared/models/videos'
import { userWatchVideo } from '../../utils/videos/video-history'
const expect = chai.expect
describe('Test videos history', function () {
let server: ServerInfo = null
let video1UUID: string
let video2UUID: string
let video3UUID: string
before(async function () {
this.timeout(30000)
await flushTests()
server = await runServer(1)
await setAccessTokensToServers([ server ])
{
const res = await uploadVideo(server.url, server.accessToken, { name: 'video 1' })
video1UUID = res.body.video.uuid
}
{
const res = await uploadVideo(server.url, server.accessToken, { name: 'video 2' })
video2UUID = res.body.video.uuid
}
{
const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' })
video3UUID = res.body.video.uuid
}
})
it('Should get videos, without watching history', async function () {
const res = await getVideosListWithToken(server.url, server.accessToken)
const videos: Video[] = res.body.data
for (const video of videos) {
const resDetail = await getVideoWithToken(server.url, server.accessToken, video.id)
const videoDetails: VideoDetails = resDetail.body
expect(video.userHistory).to.be.undefined
expect(videoDetails.userHistory).to.be.undefined
}
})
it('Should watch the first and second video', async function () {
await userWatchVideo(server.url, server.accessToken, video1UUID, 3)
await userWatchVideo(server.url, server.accessToken, video2UUID, 8)
})
it('Should return the correct history when listing, searching and getting videos', async function () {
const videosOfVideos: Video[][] = []
{
const res = await getVideosListWithToken(server.url, server.accessToken)
videosOfVideos.push(res.body.data)
}
{
const res = await searchVideoWithToken(server.url, 'video', server.accessToken)
videosOfVideos.push(res.body.data)
}
for (const videos of videosOfVideos) {
const video1 = videos.find(v => v.uuid === video1UUID)
const video2 = videos.find(v => v.uuid === video2UUID)
const video3 = videos.find(v => v.uuid === video3UUID)
expect(video1.userHistory).to.not.be.undefined
expect(video1.userHistory.currentTime).to.equal(3)
expect(video2.userHistory).to.not.be.undefined
expect(video2.userHistory.currentTime).to.equal(8)
expect(video3.userHistory).to.be.undefined
}
{
const resDetail = await getVideoWithToken(server.url, server.accessToken, video1UUID)
const videoDetails: VideoDetails = resDetail.body
expect(videoDetails.userHistory).to.not.be.undefined
expect(videoDetails.userHistory.currentTime).to.equal(3)
}
{
const resDetail = await getVideoWithToken(server.url, server.accessToken, video2UUID)
const videoDetails: VideoDetails = resDetail.body
expect(videoDetails.userHistory).to.not.be.undefined
expect(videoDetails.userHistory.currentTime).to.equal(8)
}
{
const resDetail = await getVideoWithToken(server.url, server.accessToken, video3UUID)
const videoDetails: VideoDetails = resDetail.body
expect(videoDetails.userHistory).to.be.undefined
}
})
after(async function () {
killallServers([ server ])
// Keep the logs if the test failed
if (this['ok']) {
await flushTests()
}
})
})

View File

@ -0,0 +1,14 @@
import { makePutBodyRequest } from '../requests/requests'
function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number) {
const path = '/api/v1/videos/' + videoId + '/watching'
const fields = { currentTime }
return makePutBodyRequest({ url, path, token, fields, statusCodeExpected: 204 })
}
// ---------------------------------------------------------------------------
export {
userWatchVideo
}

View File

@ -7,3 +7,4 @@ export * from './user-update-me.model'
export * from './user-right.enum' export * from './user-right.enum'
export * from './user-role' export * from './user-role'
export * from './user-video-quota.model' export * from './user-video-quota.model'
export * from './user-watching-video.model'

View File

@ -0,0 +1,3 @@
export interface UserWatchingVideo {
currentTime: number
}

View File

@ -68,6 +68,10 @@ export interface Video {
account: AccountAttribute account: AccountAttribute
channel: VideoChannelAttribute channel: VideoChannelAttribute
userHistory?: {
currentTime: number
}
} }
export interface VideoDetails extends Video { export interface VideoDetails extends Video {