diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index c1d45ea18..d25666916 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html @@ -2,9 +2,11 @@ [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" class="video-thumbnail" > - + -
- {{ video.durationLabel }} -
+
{{ video.durationLabel }}
+ +
+
+
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss index 1dd8e5338..4772edaf0 100644 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ b/client/src/app/shared/video/video-thumbnail.component.scss @@ -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 { position: absolute; right: 5px; diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts index 86d8f6f74..ca43700c7 100644 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ b/client/src/app/shared/video/video-thumbnail.component.ts @@ -22,4 +22,12 @@ export class VideoThumbnailComponent { return this.video.thumbnailUrl } + + getProgressPercent () { + if (!this.video.userHistory) return 0 + + const currentTime = this.video.userHistory.currentTime + + return (currentTime / this.video.duration) * 100 + } } diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 80794faa6..b92c96450 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts @@ -66,6 +66,10 @@ export class Video implements VideoServerModel { avatar: Avatar } + userHistory?: { + currentTime: number + } + static buildClientUrl (videoUUID: string) { return '/videos/watch/' + videoUUID } @@ -116,6 +120,8 @@ export class Video implements VideoServerModel { this.blacklisted = hash.blacklisted this.blacklistedReason = hash.blacklistedReason + + this.userHistory = hash.userHistory } isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 2255a18a2..724a0bde9 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts @@ -58,6 +58,10 @@ export class VideoService implements VideosProvider { return VideoService.BASE_VIDEO_URL + uuid + '/views' } + getUserWatchingVideoUrl (uuid: string) { + return VideoService.BASE_VIDEO_URL + uuid + '/watching' + } + getVideo (uuid: string): Observable { return this.serverService.localeObservable .pipe( diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index ea10b22ad..c5deddf05 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -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 // Re init attributes @@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.completeDescriptionShown = 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())) { const res = await this.confirmService.confirm( 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, startTime, 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) { diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index 1bf6c9267..792662b6c 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -10,7 +10,7 @@ import './webtorrent-info-button' import './peertube-videojs-plugin' import './peertube-load-progress-bar' 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 { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' @@ -34,10 +34,13 @@ function getVideojsOptions (options: { startTime: number | string theaterMode: boolean, videoCaptions: VideoJSCaption[], + language?: string, controls?: boolean, muted?: boolean, loop?: boolean + + userWatching?: UserWatching }) { const videojsOptions = { // We don't use text track settings for now @@ -57,7 +60,8 @@ function getVideojsOptions (options: { playerElement: options.playerElement, videoViewUrl: options.videoViewUrl, videoDuration: options.videoDuration, - startTime: options.startTime + startTime: options.startTime, + userWatching: options.userWatching } }, controlBar: { diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index adc376e94..2330f476f 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent' import { VideoFile } from '../../../../shared/models/videos/video.model' import { renderVideo } from './video-renderer' 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 * as CacheChunkStore from 'cache-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_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 - 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({ @@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin { private videoViewInterval private torrentInfoInterval private autoQualityInterval + private userWatchingVideoInterval private addTorrentDelay private qualityObservationTimer private runAutoQualitySchedulerTimer @@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin { this.runTorrentInfoScheduler() this.runViewAdd() + if (options.userWatching) this.runUserWatchVideo(options.userWatching) + this.player.one('play', () => { // Don't run immediately scheduler, wait some seconds the TCP connections are made this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) @@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin { clearInterval(this.torrentInfoInterval) clearInterval(this.autoQualityInterval) + if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) + // Don't need to destroy renderer, video player will be destroyed this.flushVideoFile(this.currentVideoFile, false) @@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin { }, 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 () { if (this.videoViewInterval !== undefined) { clearInterval(this.videoViewInterval) @@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin { 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) { this.disableAutoResolution(true) diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 993d5ee6b..b117007af 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -22,6 +22,11 @@ type VideoJSCaption = { src: string } +type UserWatching = { + url: string, + authorizationHeader: string +} + type PeertubePluginOptions = { videoFiles: VideoFile[] playerElement: HTMLVideoElement @@ -30,6 +35,8 @@ type PeertubePluginOptions = { startTime: number | string autoplay: boolean, videoCaptions: VideoJSCaption[] + + userWatching?: UserWatching } // videojs typings don't have some method we need @@ -39,5 +46,6 @@ export { VideoJSComponentInterface, PeertubePluginOptions, videojsUntyped, - VideoJSCaption + VideoJSCaption, + UserWatching } diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 6229c44aa..433186179 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -13,8 +13,7 @@ import { localVideoChannelValidator, videosCustomGetValidator } from '../../middlewares' -import { videosGetValidator, videosShareValidator } from '../../middlewares/validators' -import { videoCommentGetValidator } from '../../middlewares/validators/video-comments' +import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators' import { AccountModel } from '../../models/account/account' import { ActorModel } from '../../models/activitypub/actor' import { ActorFollowModel } from '../../models/activitypub/actor-follow' diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index fd4db7a54..4be2b5ef7 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts @@ -117,7 +117,8 @@ function searchVideos (req: express.Request, res: express.Response) { async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { const options = Object.assign(query, { 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) diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts index 4cf8de1ef..3ba918189 100644 --- a/server/controllers/api/videos/captions.ts +++ b/server/controllers/api/videos/captions.ts @@ -1,10 +1,6 @@ import * as express from 'express' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' -import { - addVideoCaptionValidator, - deleteVideoCaptionValidator, - listVideoCaptionsValidator -} from '../../../middlewares/validators/video-captions' +import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' import { createReqFiles } from '../../../helpers/express-utils' import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' import { getFormattedObjects } from '../../../helpers/utils' diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index dc25e1e85..4f2b4faee 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts @@ -13,14 +13,14 @@ import { setDefaultPagination, setDefaultSort } from '../../../middlewares' -import { videoCommentThreadsSortValidator } from '../../../middlewares/validators' import { addVideoCommentReplyValidator, addVideoCommentThreadValidator, listVideoCommentThreadsValidator, listVideoThreadCommentsValidator, - removeVideoCommentValidator -} from '../../../middlewares/validators/video-comments' + removeVideoCommentValidator, + videoCommentThreadsSortValidator +} from '../../../middlewares/validators' import { VideoModel } from '../../../models/video/video' import { VideoCommentModel } from '../../../models/video/video-comment' import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 15ef8d458..6a73e13d0 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -57,6 +57,7 @@ import { videoCaptionsRouter } from './captions' import { videoImportsRouter } from './import' import { resetSequelizeInstance } from '../../../helpers/database-utils' import { rename } from 'fs-extra' +import { watchingRouter } from './watching' const auditLogger = auditLoggerFactory('videos') const videosRouter = express.Router() @@ -86,6 +87,7 @@ videosRouter.use('/', videoCommentRouter) videosRouter.use('/', videoCaptionsRouter) videosRouter.use('/', videoImportsRouter) videosRouter.use('/', ownershipVideoRouter) +videosRouter.use('/', watchingRouter) videosRouter.get('/categories', listVideoCategories) videosRouter.get('/licences', listVideoLicences) @@ -119,6 +121,7 @@ videosRouter.get('/:id/description', asyncMiddleware(getVideoDescription) ) videosRouter.get('/:id', + optionalAuthenticate, asyncMiddleware(videosGetValidator), getVideo ) @@ -433,7 +436,8 @@ async function listVideos (req: express.Request, res: express.Response, next: ex tagsAllOf: req.query.tagsAllOf, nsfw: buildNSFWFilter(res, req.query.nsfw), 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)) diff --git a/server/controllers/api/videos/watching.ts b/server/controllers/api/videos/watching.ts new file mode 100644 index 000000000..e8876b47a --- /dev/null +++ b/server/controllers/api/videos/watching.ts @@ -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() +} diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 9875c68bd..714f7ac95 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -154,7 +154,9 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use } 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) { res.status(404) diff --git a/server/helpers/video.ts b/server/helpers/video.ts index b1577a6b0..1bd21467d 100644 --- a/server/helpers/video.ts +++ b/server/helpers/video.ts @@ -2,8 +2,8 @@ import { VideoModel } from '../models/video/video' type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' -function fetchVideo (id: number | string, fetchType: VideoFetchType) { - if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id) +function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) { + if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId) if (fetchType === 'only-video') return VideoModel.load(id) diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 4d57bf8aa..482c03b31 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -28,6 +28,7 @@ import { VideoImportModel } from '../models/video/video-import' import { VideoViewModel } from '../models/video/video-views' import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' 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 @@ -89,7 +90,8 @@ async function initDatabaseModels (silent: boolean) { ScheduleVideoUpdateModel, VideoImportModel, VideoViewModel, - VideoRedundancyModel + VideoRedundancyModel, + UserVideoHistoryModel ]) // Check extensions exist in the database diff --git a/server/lib/redis.ts b/server/lib/redis.ts index e4e435659..abd75d512 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts @@ -48,6 +48,8 @@ class Redis { ) } + /************* Forgot password *************/ + async setResetPasswordVerificationString (userId: number) { const generatedString = await generateRandomString(32) @@ -60,6 +62,8 @@ class Redis { return this.getValue(this.generateResetPasswordKey(userId)) } + /************* Email verification *************/ + async setVerifyEmailVerificationString (userId: number) { const generatedString = await generateRandomString(32) @@ -72,16 +76,20 @@ class Redis { return this.getValue(this.generateVerifyEmailKey(userId)) } + /************* Views per IP *************/ + 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) { - return this.exists(this.buildViewKey(ip, videoUUID)) + return this.exists(this.generateViewKey(ip, videoUUID)) } + /************* API cache *************/ + 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 } @@ -94,9 +102,11 @@ class Redis { (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) { const keyIncr = this.generateVideoViewKey(videoId) 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() return `videos-view-h${hour}` } - generateVideoViewKey (videoId: number, hour?: number) { + private generateVideoViewKey (videoId: number, hour?: number) { if (!hour) hour = new Date().getHours() return `video-view-${videoId}-h${hour}` } - generateResetPasswordKey (userId: number) { + private generateResetPasswordKey (userId: number) { return 'reset-password-' + userId } - generateVerifyEmailKey (userId: number) { + private generateVerifyEmailKey (userId: number) { return 'verify-email-' + userId } - buildViewKey (ip: string, videoUUID: string) { + private generateViewKey (ip: string, videoUUID: string) { return videoUUID + '-' + ip } - buildCachedRouteKey (req: express.Request) { - return req.method + '-' + req.originalUrl - } + /************* Redis helpers *************/ private getValue (key: string) { return new Promise((res, rej) => { @@ -197,6 +211,12 @@ class Redis { }) } + private deleteFieldInHash (key: string, field: string) { + return new Promise((res, rej) => { + this.client.hdel(this.prefix + key, field, err => err ? rej(err) : res()) + }) + } + private setValue (key: string, value: string, expirationMilliseconds: number) { return new Promise((res, rej) => { 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((res, rej) => { + this.client.hset(this.prefix + key, field, value, (err) => { + if (err) return rej(err) + + return res() + }) + }) + } + private increment (key: string) { return new Promise((res, rej) => { this.client.incr(this.prefix + key, (err, value) => { diff --git a/server/middlewares/cache.ts b/server/middlewares/cache.ts index 1b44957d3..1e00fc731 100644 --- a/server/middlewares/cache.ts +++ b/server/middlewares/cache.ts @@ -8,7 +8,7 @@ const lock = new AsyncLock({ timeout: 5000 }) function cacheRoute (lifetimeArg: string | number) { 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 { await lock.acquire(redisKey, async (done) => { diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index 940547a3e..17226614c 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -8,9 +8,5 @@ export * from './sort' export * from './users' export * from './user-subscriptions' export * from './videos' -export * from './video-abuses' -export * from './video-blacklist' -export * from './video-channels' export * from './webfinger' export * from './search' -export * from './video-imports' diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts new file mode 100644 index 000000000..294783d85 --- /dev/null +++ b/server/middlewares/validators/videos/index.ts @@ -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' diff --git a/server/middlewares/validators/video-abuses.ts b/server/middlewares/validators/videos/video-abuses.ts similarity index 88% rename from server/middlewares/validators/video-abuses.ts rename to server/middlewares/validators/videos/video-abuses.ts index f15d55a75..be26ca16a 100644 --- a/server/middlewares/validators/video-abuses.ts +++ b/server/middlewares/validators/videos/video-abuses.ts @@ -1,16 +1,16 @@ import * as express from 'express' import 'express-validator' import { body, param } from 'express-validator/check' -import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' -import { isVideoExist } from '../../helpers/custom-validators/videos' -import { logger } from '../../helpers/logger' -import { areValidationErrors } from './utils' +import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' import { isVideoAbuseExist, isVideoAbuseModerationCommentValid, isVideoAbuseReasonValid, isVideoAbuseStateValid -} from '../../helpers/custom-validators/video-abuses' +} from '../../../helpers/custom-validators/video-abuses' const videoAbuseReportValidator = [ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), diff --git a/server/middlewares/validators/video-blacklist.ts b/server/middlewares/validators/videos/video-blacklist.ts similarity index 87% rename from server/middlewares/validators/video-blacklist.ts rename to server/middlewares/validators/videos/video-blacklist.ts index 95a2b9f17..13da7acff 100644 --- a/server/middlewares/validators/video-blacklist.ts +++ b/server/middlewares/validators/videos/video-blacklist.ts @@ -1,10 +1,10 @@ import * as express from 'express' import { body, param } from 'express-validator/check' -import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' -import { isVideoExist } from '../../helpers/custom-validators/videos' -import { logger } from '../../helpers/logger' -import { areValidationErrors } from './utils' -import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' +import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' +import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist' const videosBlacklistRemoveValidator = [ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), diff --git a/server/middlewares/validators/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts similarity index 84% rename from server/middlewares/validators/video-captions.ts rename to server/middlewares/validators/videos/video-captions.ts index 51ffd7f3c..63d84fbec 100644 --- a/server/middlewares/validators/video-captions.ts +++ b/server/middlewares/validators/videos/video-captions.ts @@ -1,13 +1,13 @@ import * as express from 'express' -import { areValidationErrors } from './utils' -import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos' -import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' +import { areValidationErrors } from '../utils' +import { checkUserCanManageVideo, isVideoExist } from '../../../helpers/custom-validators/videos' +import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' import { body, param } from 'express-validator/check' -import { CONSTRAINTS_FIELDS } from '../../initializers' -import { UserRight } from '../../../shared' -import { logger } from '../../helpers/logger' -import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' -import { cleanUpReqFiles } from '../../helpers/express-utils' +import { CONSTRAINTS_FIELDS } from '../../../initializers' +import { UserRight } from '../../../../shared' +import { logger } from '../../../helpers/logger' +import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' +import { cleanUpReqFiles } from '../../../helpers/express-utils' const addVideoCaptionValidator = [ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), diff --git a/server/middlewares/validators/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts similarity index 91% rename from server/middlewares/validators/video-channels.ts rename to server/middlewares/validators/videos/video-channels.ts index 56a347b39..f039794e0 100644 --- a/server/middlewares/validators/video-channels.ts +++ b/server/middlewares/validators/videos/video-channels.ts @@ -1,20 +1,20 @@ import * as express from 'express' import { body, param } from 'express-validator/check' -import { UserRight } from '../../../shared' -import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' +import { UserRight } from '../../../../shared' +import { isAccountNameWithHostExist } from '../../../helpers/custom-validators/accounts' import { isLocalVideoChannelNameExist, isVideoChannelDescriptionValid, isVideoChannelNameValid, isVideoChannelNameWithHostExist, isVideoChannelSupportValid -} from '../../helpers/custom-validators/video-channels' -import { logger } from '../../helpers/logger' -import { UserModel } from '../../models/account/user' -import { VideoChannelModel } from '../../models/video/video-channel' -import { areValidationErrors } from './utils' -import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' -import { ActorModel } from '../../models/activitypub/actor' +} from '../../../helpers/custom-validators/video-channels' +import { logger } from '../../../helpers/logger' +import { UserModel } from '../../../models/account/user' +import { VideoChannelModel } from '../../../models/video/video-channel' +import { areValidationErrors } from '../utils' +import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' +import { ActorModel } from '../../../models/activitypub/actor' const listVideoAccountChannelsValidator = [ param('accountName').exists().withMessage('Should have a valid account name'), diff --git a/server/middlewares/validators/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts similarity index 91% rename from server/middlewares/validators/video-comments.ts rename to server/middlewares/validators/videos/video-comments.ts index 693852499..348d33082 100644 --- a/server/middlewares/validators/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts @@ -1,14 +1,14 @@ import * as express from 'express' import { body, param } from 'express-validator/check' -import { UserRight } from '../../../shared' -import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' -import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' -import { isVideoExist } from '../../helpers/custom-validators/videos' -import { logger } from '../../helpers/logger' -import { UserModel } from '../../models/account/user' -import { VideoModel } from '../../models/video/video' -import { VideoCommentModel } from '../../models/video/video-comment' -import { areValidationErrors } from './utils' +import { UserRight } from '../../../../shared' +import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { UserModel } from '../../../models/account/user' +import { VideoModel } from '../../../models/video/video' +import { VideoCommentModel } from '../../../models/video/video-comment' +import { areValidationErrors } from '../utils' const listVideoCommentThreadsValidator = [ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), diff --git a/server/middlewares/validators/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts similarity index 84% rename from server/middlewares/validators/video-imports.ts rename to server/middlewares/validators/videos/video-imports.ts index b2063b8da..48d20f904 100644 --- a/server/middlewares/validators/video-imports.ts +++ b/server/middlewares/validators/videos/video-imports.ts @@ -1,14 +1,14 @@ import * as express from 'express' import { body } from 'express-validator/check' -import { isIdValid } from '../../helpers/custom-validators/misc' -import { logger } from '../../helpers/logger' -import { areValidationErrors } from './utils' +import { isIdValid } from '../../../helpers/custom-validators/misc' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' import { getCommonVideoAttributes } from './videos' -import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../helpers/custom-validators/video-imports' -import { cleanUpReqFiles } from '../../helpers/express-utils' -import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos' -import { CONFIG } from '../../initializers/constants' -import { CONSTRAINTS_FIELDS } from '../../initializers' +import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' +import { cleanUpReqFiles } from '../../../helpers/express-utils' +import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' +import { CONFIG } from '../../../initializers/constants' +import { CONSTRAINTS_FIELDS } from '../../../initializers' const videoImportAddValidator = getCommonVideoAttributes().concat([ body('channelId') diff --git a/server/middlewares/validators/videos/video-watch.ts b/server/middlewares/validators/videos/video-watch.ts new file mode 100644 index 000000000..bca64662f --- /dev/null +++ b/server/middlewares/validators/videos/video-watch.ts @@ -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 +} diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos/videos.ts similarity index 92% rename from server/middlewares/validators/videos.ts rename to server/middlewares/validators/videos/videos.ts index 67eabe468..d6b8aa725 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -1,7 +1,7 @@ import * as express from 'express' import 'express-validator' import { body, param, ValidationChain } from 'express-validator/check' -import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../shared' +import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' import { isBooleanValid, isDateValid, @@ -10,7 +10,7 @@ import { isUUIDValid, toIntOrNull, toValueOrNull -} from '../../helpers/custom-validators/misc' +} from '../../../helpers/custom-validators/misc' import { checkUserCanManageVideo, isScheduleVideoUpdatePrivacyValid, @@ -27,21 +27,21 @@ import { isVideoRatingTypeValid, isVideoSupportValid, isVideoTagsValid -} from '../../helpers/custom-validators/videos' -import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' -import { logger } from '../../helpers/logger' -import { CONSTRAINTS_FIELDS } from '../../initializers' -import { VideoShareModel } from '../../models/video/video-share' -import { authenticate } from '../oauth' -import { areValidationErrors } from './utils' -import { cleanUpReqFiles } from '../../helpers/express-utils' -import { VideoModel } from '../../models/video/video' -import { UserModel } from '../../models/account/user' -import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../helpers/custom-validators/video-ownership' -import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model' -import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' -import { AccountModel } from '../../models/account/account' -import { VideoFetchType } from '../../helpers/video' +} from '../../../helpers/custom-validators/videos' +import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' +import { logger } from '../../../helpers/logger' +import { CONSTRAINTS_FIELDS } from '../../../initializers' +import { VideoShareModel } from '../../../models/video/video-share' +import { authenticate } from '../../oauth' +import { areValidationErrors } from '../utils' +import { cleanUpReqFiles } from '../../../helpers/express-utils' +import { VideoModel } from '../../../models/video/video' +import { UserModel } from '../../../models/account/user' +import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership' +import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' +import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership' +import { AccountModel } from '../../../models/account/account' +import { VideoFetchType } from '../../../helpers/video' const videosAddValidator = getCommonVideoAttributes().concat([ body('videofile') diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts new file mode 100644 index 000000000..0476cad9d --- /dev/null +++ b/server/models/account/user-video-history.ts @@ -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 { + @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 +} diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index f23dde9b8..78972b199 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts @@ -10,6 +10,7 @@ import { getVideoLikesActivityPubUrl, getVideoSharesActivityPubUrl } from '../../lib/activitypub' +import { isArray } from 'util' export type VideoFormattingJSONOptions = { completeDescription?: boolean @@ -24,6 +25,8 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting const formattedAccount = video.VideoChannel.Account.toFormattedJSON() const formattedVideoChannel = video.VideoChannel.toFormattedJSON() + const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined + const videoObject: Video = { id: video.id, uuid: video.uuid, @@ -74,7 +77,11 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting url: formattedVideoChannel.url, host: formattedVideoChannel.host, avatar: formattedVideoChannel.avatar - } + }, + + userHistory: userHistory ? { + currentTime: userHistory.currentTime + } : undefined } if (options) { diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 6c89c16bf..0a2d7e6de 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -92,6 +92,8 @@ import { videoModelToFormattedJSON } from './video-format-utils' 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 const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -127,7 +129,8 @@ export enum ScopeNames { WITH_TAGS = 'WITH_TAGS', WITH_FILES = 'WITH_FILES', WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', - WITH_BLACKLISTED = 'WITH_BLACKLISTED' + WITH_BLACKLISTED = 'WITH_BLACKLISTED', + WITH_USER_HISTORY = 'WITH_USER_HISTORY' } type ForAPIOptions = { @@ -464,6 +467,8 @@ type AvailableForListIDsOptions = { include: [ { 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, include: [ { @@ -482,6 +487,20 @@ type AvailableForListIDsOptions = { required: false } ] + }, + [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => { + return { + include: [ + { + attributes: [ 'currentTime' ], + model: UserVideoHistoryModel.unscoped(), + required: false, + where: { + userId + } + } + ] + } } }) @Table({ @@ -672,11 +691,19 @@ export class VideoModel extends Model { name: 'videoId', allowNull: false }, - onDelete: 'cascade', - hooks: true + onDelete: 'cascade' }) VideoViews: VideoViewModel[] + @HasMany(() => UserVideoHistoryModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + UserVideoHistories: UserVideoHistoryModel[] + @HasOne(() => ScheduleVideoUpdateModel, { foreignKey: { name: 'videoId', @@ -930,7 +957,8 @@ export class VideoModel extends Model { accountId?: number, videoChannelId?: number, actorId?: number - trendingDays?: number + trendingDays?: number, + userId?: number }, countVideos = true) { const query: IFindOptions = { offset: options.start, @@ -961,6 +989,7 @@ export class VideoModel extends Model { accountId: options.accountId, videoChannelId: options.videoChannelId, includeLocalVideos: options.includeLocalVideos, + userId: options.userId, trendingDays } @@ -983,6 +1012,7 @@ export class VideoModel extends Model { tagsAllOf?: string[] durationMin?: number // seconds durationMax?: number // seconds + userId?: number }) { const whereAnd = [] @@ -1058,7 +1088,8 @@ export class VideoModel extends Model { licenceOneOf: options.licenceOneOf, languageOneOf: options.languageOneOf, tagsOneOf: options.tagsOneOf, - tagsAllOf: options.tagsAllOf + tagsAllOf: options.tagsAllOf, + userId: options.userId } return VideoModel.getAvailableForApi(query, queryOptions) @@ -1125,7 +1156,7 @@ export class VideoModel extends Model { 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 options = { @@ -1134,14 +1165,20 @@ export class VideoModel extends Model { 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 - .scope([ - ScopeNames.WITH_TAGS, - ScopeNames.WITH_BLACKLISTED, - ScopeNames.WITH_FILES, - ScopeNames.WITH_ACCOUNT_DETAILS, - ScopeNames.WITH_SCHEDULED_UPDATE - ]) + .scope(scopes) .findOne(options) } @@ -1225,7 +1262,11 @@ export class VideoModel extends Model { return {} } - private static async getAvailableForApi (query: IFindOptions, options: AvailableForListIDsOptions, countVideos = true) { + private static async getAvailableForApi ( + query: IFindOptions, + options: AvailableForListIDsOptions & { userId?: number}, + countVideos = true + ) { const idsScope = { method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, options @@ -1249,8 +1290,15 @@ export class VideoModel extends Model { if (ids.length === 0) return { data: [], total: count } - const apiScope = { - method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] + // FIXME: typings + 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 = { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 44460a167..71a217649 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -15,3 +15,4 @@ import './video-channels' import './video-comments' import './video-imports' import './videos' +import './videos-history' diff --git a/server/tests/api/check-params/videos-history.ts b/server/tests/api/check-params/videos-history.ts new file mode 100644 index 000000000..808c3b616 --- /dev/null +++ b/server/tests/api/check-params/videos-history.ts @@ -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() + } + }) +}) diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index bf58f9c79..09bb62a8d 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -14,4 +14,5 @@ import './video-nsfw' import './video-privacy' import './video-schedule-update' import './video-transcoder' +import './videos-history' import './videos-overview' diff --git a/server/tests/api/videos/videos-history.ts b/server/tests/api/videos/videos-history.ts new file mode 100644 index 000000000..6d289b288 --- /dev/null +++ b/server/tests/api/videos/videos-history.ts @@ -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() + } + }) +}) diff --git a/server/tests/utils/videos/video-history.ts b/server/tests/utils/videos/video-history.ts new file mode 100644 index 000000000..7635478f7 --- /dev/null +++ b/server/tests/utils/videos/video-history.ts @@ -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 +} diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts index 15c2f99c2..7114741e0 100644 --- a/shared/models/users/index.ts +++ b/shared/models/users/index.ts @@ -7,3 +7,4 @@ export * from './user-update-me.model' export * from './user-right.enum' export * from './user-role' export * from './user-video-quota.model' +export * from './user-watching-video.model' diff --git a/shared/models/users/user-watching-video.model.ts b/shared/models/users/user-watching-video.model.ts new file mode 100644 index 000000000..c22480595 --- /dev/null +++ b/shared/models/users/user-watching-video.model.ts @@ -0,0 +1,3 @@ +export interface UserWatchingVideo { + currentTime: number +} diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index b47ab1ab8..4a9fa58b1 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -68,6 +68,10 @@ export interface Video { account: AccountAttribute channel: VideoChannelAttribute + + userHistory?: { + currentTime: number + } } export interface VideoDetails extends Video {