aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-10-05 11:15:06 +0200
committerChocobozzz <me@florianbigard.com>2018-10-05 11:22:38 +0200
commit6e46de095d7169355dd83030f6ce4a582304153a (patch)
treedfa78e2008d3d135a00b798b05350b4975145acc
parenta585824160d016db7c9bff0e1cb1ffa3aaf73d74 (diff)
downloadPeerTube-6e46de095d7169355dd83030f6ce4a582304153a.tar.gz
PeerTube-6e46de095d7169355dd83030f6ce4a582304153a.tar.zst
PeerTube-6e46de095d7169355dd83030f6ce4a582304153a.zip
Add user history and resume videos
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html10
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss13
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts8
-rw-r--r--client/src/app/shared/video/video.model.ts6
-rw-r--r--client/src/app/shared/video/video.service.ts4
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts13
-rw-r--r--client/src/assets/player/peertube-player.ts8
-rw-r--r--client/src/assets/player/peertube-videojs-plugin.ts34
-rw-r--r--client/src/assets/player/peertube-videojs-typings.ts10
-rw-r--r--server/controllers/activitypub/client.ts3
-rw-r--r--server/controllers/api/search.ts3
-rw-r--r--server/controllers/api/videos/captions.ts6
-rw-r--r--server/controllers/api/videos/comment.ts6
-rw-r--r--server/controllers/api/videos/index.ts6
-rw-r--r--server/controllers/api/videos/watching.ts36
-rw-r--r--server/helpers/custom-validators/videos.ts4
-rw-r--r--server/helpers/video.ts4
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/lib/redis.ts54
-rw-r--r--server/middlewares/cache.ts2
-rw-r--r--server/middlewares/validators/index.ts4
-rw-r--r--server/middlewares/validators/videos/index.ts8
-rw-r--r--server/middlewares/validators/videos/video-abuses.ts (renamed from server/middlewares/validators/video-abuses.ts)10
-rw-r--r--server/middlewares/validators/videos/video-blacklist.ts (renamed from server/middlewares/validators/video-blacklist.ts)10
-rw-r--r--server/middlewares/validators/videos/video-captions.ts (renamed from server/middlewares/validators/video-captions.ts)16
-rw-r--r--server/middlewares/validators/videos/video-channels.ts (renamed from server/middlewares/validators/video-channels.ts)18
-rw-r--r--server/middlewares/validators/videos/video-comments.ts (renamed from server/middlewares/validators/video-comments.ts)18
-rw-r--r--server/middlewares/validators/videos/video-imports.ts (renamed from server/middlewares/validators/video-imports.ts)16
-rw-r--r--server/middlewares/validators/videos/video-watch.ts28
-rw-r--r--server/middlewares/validators/videos/videos.ts (renamed from server/middlewares/validators/videos.ts)34
-rw-r--r--server/models/account/user-video-history.ts55
-rw-r--r--server/models/video/video-format-utils.ts9
-rw-r--r--server/models/video/video.ts80
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/videos-history.ts79
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/videos-history.ts128
-rw-r--r--server/tests/utils/videos/video-history.ts14
-rw-r--r--shared/models/users/index.ts1
-rw-r--r--shared/models/users/user-watching-video.model.ts3
-rw-r--r--shared/models/videos/video.model.ts4
41 files changed, 649 insertions, 122 deletions
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 @@
2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" 2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5<img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
6 6
7<div class="video-thumbnail-overlay"> 7 <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
8 {{ video.durationLabel }} 8
9</div> 9 <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
10 <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
11 </div>
10</a> 12</a>
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 @@
29 } 29 }
30 } 30 }
31 31
32 .progress-bar {
33 height: 3px;
34 width: 100%;
35 position: relative;
36 top: -3px;
37 background-color: rgba(0, 0, 0, 0.20);
38
39 div {
40 height: 100%;
41 background-color: var(--mainColor);
42 }
43 }
44
32 .video-thumbnail-overlay { 45 .video-thumbnail-overlay {
33 position: absolute; 46 position: absolute;
34 right: 5px; 47 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 {
22 22
23 return this.video.thumbnailUrl 23 return this.video.thumbnailUrl
24 } 24 }
25
26 getProgressPercent () {
27 if (!this.video.userHistory) return 0
28
29 const currentTime = this.video.userHistory.currentTime
30
31 return (currentTime / this.video.duration) * 100
32 }
25} 33}
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 {
66 avatar: Avatar 66 avatar: Avatar
67 } 67 }
68 68
69 userHistory?: {
70 currentTime: number
71 }
72
69 static buildClientUrl (videoUUID: string) { 73 static buildClientUrl (videoUUID: string) {
70 return '/videos/watch/' + videoUUID 74 return '/videos/watch/' + videoUUID
71 } 75 }
@@ -116,6 +120,8 @@ export class Video implements VideoServerModel {
116 120
117 this.blacklisted = hash.blacklisted 121 this.blacklisted = hash.blacklisted
118 this.blacklistedReason = hash.blacklistedReason 122 this.blacklistedReason = hash.blacklistedReason
123
124 this.userHistory = hash.userHistory
119 } 125 }
120 126
121 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 127 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 {
58 return VideoService.BASE_VIDEO_URL + uuid + '/views' 58 return VideoService.BASE_VIDEO_URL + uuid + '/views'
59 } 59 }
60 60
61 getUserWatchingVideoUrl (uuid: string) {
62 return VideoService.BASE_VIDEO_URL + uuid + '/watching'
63 }
64
61 getVideo (uuid: string): Observable<VideoDetails> { 65 getVideo (uuid: string): Observable<VideoDetails> {
62 return this.serverService.localeObservable 66 return this.serverService.localeObservable
63 .pipe( 67 .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 {
369 ) 369 )
370 } 370 }
371 371
372 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) { 372 private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTimeFromUrl: number) {
373 this.video = video 373 this.video = video
374 374
375 // Re init attributes 375 // Re init attributes
@@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
377 this.completeDescriptionShown = false 377 this.completeDescriptionShown = false
378 this.remoteServerDown = false 378 this.remoteServerDown = false
379 379
380 let startTime = startTimeFromUrl || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
381 // Don't start the video if we are at the end
382 if (this.video.duration - startTime <= 1) startTime = 0
383
380 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { 384 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
381 const res = await this.confirmService.confirm( 385 const res = await this.confirmService.confirm(
382 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), 386 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 {
414 poster: this.video.previewUrl, 418 poster: this.video.previewUrl,
415 startTime, 419 startTime,
416 theaterMode: true, 420 theaterMode: true,
417 language: this.localeId 421 language: this.localeId,
422
423 userWatching: this.user ? {
424 url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
425 authorizationHeader: this.authService.getRequestHeaderValue()
426 } : undefined
418 }) 427 })
419 428
420 if (this.videojsLocaleLoaded === false) { 429 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'
10import './peertube-videojs-plugin' 10import './peertube-videojs-plugin'
11import './peertube-load-progress-bar' 11import './peertube-load-progress-bar'
12import './theater-button' 12import './theater-button'
13import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' 13import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' 14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' 15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
16 16
@@ -34,10 +34,13 @@ function getVideojsOptions (options: {
34 startTime: number | string 34 startTime: number | string
35 theaterMode: boolean, 35 theaterMode: boolean,
36 videoCaptions: VideoJSCaption[], 36 videoCaptions: VideoJSCaption[],
37
37 language?: string, 38 language?: string,
38 controls?: boolean, 39 controls?: boolean,
39 muted?: boolean, 40 muted?: boolean,
40 loop?: boolean 41 loop?: boolean
42
43 userWatching?: UserWatching
41}) { 44}) {
42 const videojsOptions = { 45 const videojsOptions = {
43 // We don't use text track settings for now 46 // We don't use text track settings for now
@@ -57,7 +60,8 @@ function getVideojsOptions (options: {
57 playerElement: options.playerElement, 60 playerElement: options.playerElement,
58 videoViewUrl: options.videoViewUrl, 61 videoViewUrl: options.videoViewUrl,
59 videoDuration: options.videoDuration, 62 videoDuration: options.videoDuration,
60 startTime: options.startTime 63 startTime: options.startTime,
64 userWatching: options.userWatching
61 } 65 }
62 }, 66 },
63 controlBar: { 67 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'
3import { VideoFile } from '../../../../shared/models/videos/video.model' 3import { VideoFile } from '../../../../shared/models/videos/video.model'
4import { renderVideo } from './video-renderer' 4import { renderVideo } from './video-renderer'
5import './settings-menu-button' 5import './settings-menu-button'
6import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 6import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
7import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' 7import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
8import * as CacheChunkStore from 'cache-chunk-store' 8import * as CacheChunkStore from 'cache-chunk-store'
9import { PeertubeChunkStore } from './peertube-chunk-store' 9import { PeertubeChunkStore } from './peertube-chunk-store'
@@ -32,7 +32,8 @@ class PeerTubePlugin extends Plugin {
32 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it 32 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
33 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check 33 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
34 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds 34 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
35 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth 35 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth
36 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
36 } 37 }
37 38
38 private readonly webtorrent = new WebTorrent({ 39 private readonly webtorrent = new WebTorrent({
@@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin {
67 private videoViewInterval 68 private videoViewInterval
68 private torrentInfoInterval 69 private torrentInfoInterval
69 private autoQualityInterval 70 private autoQualityInterval
71 private userWatchingVideoInterval
70 private addTorrentDelay 72 private addTorrentDelay
71 private qualityObservationTimer 73 private qualityObservationTimer
72 private runAutoQualitySchedulerTimer 74 private runAutoQualitySchedulerTimer
@@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin {
100 this.runTorrentInfoScheduler() 102 this.runTorrentInfoScheduler()
101 this.runViewAdd() 103 this.runViewAdd()
102 104
105 if (options.userWatching) this.runUserWatchVideo(options.userWatching)
106
103 this.player.one('play', () => { 107 this.player.one('play', () => {
104 // Don't run immediately scheduler, wait some seconds the TCP connections are made 108 // Don't run immediately scheduler, wait some seconds the TCP connections are made
105 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) 109 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
@@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin {
121 clearInterval(this.torrentInfoInterval) 125 clearInterval(this.torrentInfoInterval)
122 clearInterval(this.autoQualityInterval) 126 clearInterval(this.autoQualityInterval)
123 127
128 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
129
124 // Don't need to destroy renderer, video player will be destroyed 130 // Don't need to destroy renderer, video player will be destroyed
125 this.flushVideoFile(this.currentVideoFile, false) 131 this.flushVideoFile(this.currentVideoFile, false)
126 132
@@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin {
524 }, 1000) 530 }, 1000)
525 } 531 }
526 532
533 private runUserWatchVideo (options: UserWatching) {
534 let lastCurrentTime = 0
535
536 this.userWatchingVideoInterval = setInterval(() => {
537 const currentTime = Math.floor(this.player.currentTime())
538
539 if (currentTime - lastCurrentTime >= 1) {
540 lastCurrentTime = currentTime
541
542 this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
543 .catch(err => console.error('Cannot notify user is watching.', err))
544 }
545 }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
546 }
547
527 private clearVideoViewInterval () { 548 private clearVideoViewInterval () {
528 if (this.videoViewInterval !== undefined) { 549 if (this.videoViewInterval !== undefined) {
529 clearInterval(this.videoViewInterval) 550 clearInterval(this.videoViewInterval)
@@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin {
537 return fetch(this.videoViewUrl, { method: 'POST' }) 558 return fetch(this.videoViewUrl, { method: 'POST' })
538 } 559 }
539 560
561 private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
562 const body = new URLSearchParams()
563 body.append('currentTime', currentTime.toString())
564
565 const headers = new Headers({ 'Authorization': authorizationHeader })
566
567 return fetch(url, { method: 'PUT', body, headers })
568 }
569
540 private fallbackToHttp (done?: Function, play = true) { 570 private fallbackToHttp (done?: Function, play = true) {
541 this.disableAutoResolution(true) 571 this.disableAutoResolution(true)
542 572
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 = {
22 src: string 22 src: string
23} 23}
24 24
25type UserWatching = {
26 url: string,
27 authorizationHeader: string
28}
29
25type PeertubePluginOptions = { 30type PeertubePluginOptions = {
26 videoFiles: VideoFile[] 31 videoFiles: VideoFile[]
27 playerElement: HTMLVideoElement 32 playerElement: HTMLVideoElement
@@ -30,6 +35,8 @@ type PeertubePluginOptions = {
30 startTime: number | string 35 startTime: number | string
31 autoplay: boolean, 36 autoplay: boolean,
32 videoCaptions: VideoJSCaption[] 37 videoCaptions: VideoJSCaption[]
38
39 userWatching?: UserWatching
33} 40}
34 41
35// videojs typings don't have some method we need 42// videojs typings don't have some method we need
@@ -39,5 +46,6 @@ export {
39 VideoJSComponentInterface, 46 VideoJSComponentInterface,
40 PeertubePluginOptions, 47 PeertubePluginOptions,
41 videojsUntyped, 48 videojsUntyped,
42 VideoJSCaption 49 VideoJSCaption,
50 UserWatching
43} 51}
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 {
13 localVideoChannelValidator, 13 localVideoChannelValidator,
14 videosCustomGetValidator 14 videosCustomGetValidator
15} from '../../middlewares' 15} from '../../middlewares'
16import { videosGetValidator, videosShareValidator } from '../../middlewares/validators' 16import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators'
17import { videoCommentGetValidator } from '../../middlewares/validators/video-comments'
18import { AccountModel } from '../../models/account/account' 17import { AccountModel } from '../../models/account/account'
19import { ActorModel } from '../../models/activitypub/actor' 18import { ActorModel } from '../../models/activitypub/actor'
20import { ActorFollowModel } from '../../models/activitypub/actor-follow' 19import { 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) {
117async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { 117async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
118 const options = Object.assign(query, { 118 const options = Object.assign(query, {
119 includeLocalVideos: true, 119 includeLocalVideos: true,
120 nsfw: buildNSFWFilter(res, query.nsfw) 120 nsfw: buildNSFWFilter(res, query.nsfw),
121 userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
121 }) 122 })
122 const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) 123 const resultList = await VideoModel.searchAndPopulateAccountAndServer(options)
123 124
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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' 2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
3import { 3import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
4 addVideoCaptionValidator,
5 deleteVideoCaptionValidator,
6 listVideoCaptionsValidator
7} from '../../../middlewares/validators/video-captions'
8import { createReqFiles } from '../../../helpers/express-utils' 4import { createReqFiles } from '../../../helpers/express-utils'
9import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' 5import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
10import { getFormattedObjects } from '../../../helpers/utils' 6import { 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 {
13 setDefaultPagination, 13 setDefaultPagination,
14 setDefaultSort 14 setDefaultSort
15} from '../../../middlewares' 15} from '../../../middlewares'
16import { videoCommentThreadsSortValidator } from '../../../middlewares/validators'
17import { 16import {
18 addVideoCommentReplyValidator, 17 addVideoCommentReplyValidator,
19 addVideoCommentThreadValidator, 18 addVideoCommentThreadValidator,
20 listVideoCommentThreadsValidator, 19 listVideoCommentThreadsValidator,
21 listVideoThreadCommentsValidator, 20 listVideoThreadCommentsValidator,
22 removeVideoCommentValidator 21 removeVideoCommentValidator,
23} from '../../../middlewares/validators/video-comments' 22 videoCommentThreadsSortValidator
23} from '../../../middlewares/validators'
24import { VideoModel } from '../../../models/video/video' 24import { VideoModel } from '../../../models/video/video'
25import { VideoCommentModel } from '../../../models/video/video-comment' 25import { VideoCommentModel } from '../../../models/video/video-comment'
26import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' 26import { 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'
57import { videoImportsRouter } from './import' 57import { videoImportsRouter } from './import'
58import { resetSequelizeInstance } from '../../../helpers/database-utils' 58import { resetSequelizeInstance } from '../../../helpers/database-utils'
59import { rename } from 'fs-extra' 59import { rename } from 'fs-extra'
60import { watchingRouter } from './watching'
60 61
61const auditLogger = auditLoggerFactory('videos') 62const auditLogger = auditLoggerFactory('videos')
62const videosRouter = express.Router() 63const videosRouter = express.Router()
@@ -86,6 +87,7 @@ videosRouter.use('/', videoCommentRouter)
86videosRouter.use('/', videoCaptionsRouter) 87videosRouter.use('/', videoCaptionsRouter)
87videosRouter.use('/', videoImportsRouter) 88videosRouter.use('/', videoImportsRouter)
88videosRouter.use('/', ownershipVideoRouter) 89videosRouter.use('/', ownershipVideoRouter)
90videosRouter.use('/', watchingRouter)
89 91
90videosRouter.get('/categories', listVideoCategories) 92videosRouter.get('/categories', listVideoCategories)
91videosRouter.get('/licences', listVideoLicences) 93videosRouter.get('/licences', listVideoLicences)
@@ -119,6 +121,7 @@ videosRouter.get('/:id/description',
119 asyncMiddleware(getVideoDescription) 121 asyncMiddleware(getVideoDescription)
120) 122)
121videosRouter.get('/:id', 123videosRouter.get('/:id',
124 optionalAuthenticate,
122 asyncMiddleware(videosGetValidator), 125 asyncMiddleware(videosGetValidator),
123 getVideo 126 getVideo
124) 127)
@@ -433,7 +436,8 @@ async function listVideos (req: express.Request, res: express.Response, next: ex
433 tagsAllOf: req.query.tagsAllOf, 436 tagsAllOf: req.query.tagsAllOf,
434 nsfw: buildNSFWFilter(res, req.query.nsfw), 437 nsfw: buildNSFWFilter(res, req.query.nsfw),
435 filter: req.query.filter as VideoFilter, 438 filter: req.query.filter as VideoFilter,
436 withFiles: false 439 withFiles: false,
440 userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
437 }) 441 })
438 442
439 return res.json(getFormattedObjects(resultList.data, resultList.total)) 443 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 @@
1import * as express from 'express'
2import { UserWatchingVideo } from '../../../../shared'
3import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares'
4import { UserVideoHistoryModel } from '../../../models/account/user-video-history'
5import { UserModel } from '../../../models/account/user'
6
7const watchingRouter = express.Router()
8
9watchingRouter.put('/:videoId/watching',
10 authenticate,
11 asyncMiddleware(videoWatchingValidator),
12 asyncRetryTransactionMiddleware(userWatchVideo)
13)
14
15// ---------------------------------------------------------------------------
16
17export {
18 watchingRouter
19}
20
21// ---------------------------------------------------------------------------
22
23async function userWatchVideo (req: express.Request, res: express.Response) {
24 const user = res.locals.oauth.token.User as UserModel
25
26 const body: UserWatchingVideo = req.body
27 const { id: videoId } = res.locals.video as { id: number }
28
29 await UserVideoHistoryModel.upsert({
30 videoId,
31 userId: user.id,
32 currentTime: body.currentTime
33 })
34
35 return res.type('json').status(204).end()
36}
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
154} 154}
155 155
156async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { 156async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
157 const video = await fetchVideo(id, fetchType) 157 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
158
159 const video = await fetchVideo(id, fetchType, userId)
158 160
159 if (video === null) { 161 if (video === null) {
160 res.status(404) 162 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'
2 2
3type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' 3type VideoFetchType = 'all' | 'only-video' | 'id' | 'none'
4 4
5function fetchVideo (id: number | string, fetchType: VideoFetchType) { 5function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) {
6 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id) 6 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
7 7
8 if (fetchType === 'only-video') return VideoModel.load(id) 8 if (fetchType === 'only-video') return VideoModel.load(id)
9 9
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'
28import { VideoViewModel } from '../models/video/video-views' 28import { VideoViewModel } from '../models/video/video-views'
29import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' 29import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
30import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' 30import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
31import { UserVideoHistoryModel } from '../models/account/user-video-history'
31 32
32require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 33require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
33 34
@@ -89,7 +90,8 @@ async function initDatabaseModels (silent: boolean) {
89 ScheduleVideoUpdateModel, 90 ScheduleVideoUpdateModel,
90 VideoImportModel, 91 VideoImportModel,
91 VideoViewModel, 92 VideoViewModel,
92 VideoRedundancyModel 93 VideoRedundancyModel,
94 UserVideoHistoryModel
93 ]) 95 ])
94 96
95 // Check extensions exist in the database 97 // 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 {
48 ) 48 )
49 } 49 }
50 50
51 /************* Forgot password *************/
52
51 async setResetPasswordVerificationString (userId: number) { 53 async setResetPasswordVerificationString (userId: number) {
52 const generatedString = await generateRandomString(32) 54 const generatedString = await generateRandomString(32)
53 55
@@ -60,6 +62,8 @@ class Redis {
60 return this.getValue(this.generateResetPasswordKey(userId)) 62 return this.getValue(this.generateResetPasswordKey(userId))
61 } 63 }
62 64
65 /************* Email verification *************/
66
63 async setVerifyEmailVerificationString (userId: number) { 67 async setVerifyEmailVerificationString (userId: number) {
64 const generatedString = await generateRandomString(32) 68 const generatedString = await generateRandomString(32)
65 69
@@ -72,16 +76,20 @@ class Redis {
72 return this.getValue(this.generateVerifyEmailKey(userId)) 76 return this.getValue(this.generateVerifyEmailKey(userId))
73 } 77 }
74 78
79 /************* Views per IP *************/
80
75 setIPVideoView (ip: string, videoUUID: string) { 81 setIPVideoView (ip: string, videoUUID: string) {
76 return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) 82 return this.setValue(this.generateViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME)
77 } 83 }
78 84
79 async isVideoIPViewExists (ip: string, videoUUID: string) { 85 async isVideoIPViewExists (ip: string, videoUUID: string) {
80 return this.exists(this.buildViewKey(ip, videoUUID)) 86 return this.exists(this.generateViewKey(ip, videoUUID))
81 } 87 }
82 88
89 /************* API cache *************/
90
83 async getCachedRoute (req: express.Request) { 91 async getCachedRoute (req: express.Request) {
84 const cached = await this.getObject(this.buildCachedRouteKey(req)) 92 const cached = await this.getObject(this.generateCachedRouteKey(req))
85 93
86 return cached as CachedRoute 94 return cached as CachedRoute
87 } 95 }
@@ -94,9 +102,11 @@ class Redis {
94 (statusCode) ? { statusCode: statusCode.toString() } : null 102 (statusCode) ? { statusCode: statusCode.toString() } : null
95 ) 103 )
96 104
97 return this.setObject(this.buildCachedRouteKey(req), cached, lifetime) 105 return this.setObject(this.generateCachedRouteKey(req), cached, lifetime)
98 } 106 }
99 107
108 /************* Video views *************/
109
100 addVideoView (videoId: number) { 110 addVideoView (videoId: number) {
101 const keyIncr = this.generateVideoViewKey(videoId) 111 const keyIncr = this.generateVideoViewKey(videoId)
102 const keySet = this.generateVideosViewKey() 112 const keySet = this.generateVideosViewKey()
@@ -131,33 +141,37 @@ class Redis {
131 ]) 141 ])
132 } 142 }
133 143
134 generateVideosViewKey (hour?: number) { 144 /************* Keys generation *************/
145
146 generateCachedRouteKey (req: express.Request) {
147 return req.method + '-' + req.originalUrl
148 }
149
150 private generateVideosViewKey (hour?: number) {
135 if (!hour) hour = new Date().getHours() 151 if (!hour) hour = new Date().getHours()
136 152
137 return `videos-view-h${hour}` 153 return `videos-view-h${hour}`
138 } 154 }
139 155
140 generateVideoViewKey (videoId: number, hour?: number) { 156 private generateVideoViewKey (videoId: number, hour?: number) {
141 if (!hour) hour = new Date().getHours() 157 if (!hour) hour = new Date().getHours()
142 158
143 return `video-view-${videoId}-h${hour}` 159 return `video-view-${videoId}-h${hour}`
144 } 160 }
145 161
146 generateResetPasswordKey (userId: number) { 162 private generateResetPasswordKey (userId: number) {
147 return 'reset-password-' + userId 163 return 'reset-password-' + userId
148 } 164 }
149 165
150 generateVerifyEmailKey (userId: number) { 166 private generateVerifyEmailKey (userId: number) {
151 return 'verify-email-' + userId 167 return 'verify-email-' + userId
152 } 168 }
153 169
154 buildViewKey (ip: string, videoUUID: string) { 170 private generateViewKey (ip: string, videoUUID: string) {
155 return videoUUID + '-' + ip 171 return videoUUID + '-' + ip
156 } 172 }
157 173
158 buildCachedRouteKey (req: express.Request) { 174 /************* Redis helpers *************/
159 return req.method + '-' + req.originalUrl
160 }
161 175
162 private getValue (key: string) { 176 private getValue (key: string) {
163 return new Promise<string>((res, rej) => { 177 return new Promise<string>((res, rej) => {
@@ -197,6 +211,12 @@ class Redis {
197 }) 211 })
198 } 212 }
199 213
214 private deleteFieldInHash (key: string, field: string) {
215 return new Promise<void>((res, rej) => {
216 this.client.hdel(this.prefix + key, field, err => err ? rej(err) : res())
217 })
218 }
219
200 private setValue (key: string, value: string, expirationMilliseconds: number) { 220 private setValue (key: string, value: string, expirationMilliseconds: number) {
201 return new Promise<void>((res, rej) => { 221 return new Promise<void>((res, rej) => {
202 this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { 222 this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => {
@@ -235,6 +255,16 @@ class Redis {
235 }) 255 })
236 } 256 }
237 257
258 private setValueInHash (key: string, field: string, value: string) {
259 return new Promise<void>((res, rej) => {
260 this.client.hset(this.prefix + key, field, value, (err) => {
261 if (err) return rej(err)
262
263 return res()
264 })
265 })
266 }
267
238 private increment (key: string) { 268 private increment (key: string) {
239 return new Promise<number>((res, rej) => { 269 return new Promise<number>((res, rej) => {
240 this.client.incr(this.prefix + key, (err, value) => { 270 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 })
8 8
9function cacheRoute (lifetimeArg: string | number) { 9function cacheRoute (lifetimeArg: string | number) {
10 return async function (req: express.Request, res: express.Response, next: express.NextFunction) { 10 return async function (req: express.Request, res: express.Response, next: express.NextFunction) {
11 const redisKey = Redis.Instance.buildCachedRouteKey(req) 11 const redisKey = Redis.Instance.generateCachedRouteKey(req)
12 12
13 try { 13 try {
14 await lock.acquire(redisKey, async (done) => { 14 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'
8export * from './users' 8export * from './users'
9export * from './user-subscriptions' 9export * from './user-subscriptions'
10export * from './videos' 10export * from './videos'
11export * from './video-abuses'
12export * from './video-blacklist'
13export * from './video-channels'
14export * from './webfinger' 11export * from './webfinger'
15export * from './search' 12export * from './search'
16export * 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 @@
1export * from './video-abuses'
2export * from './video-blacklist'
3export * from './video-captions'
4export * from './video-channels'
5export * from './video-comments'
6export * from './video-imports'
7export * from './video-watch'
8export * from './videos'
diff --git a/server/middlewares/validators/video-abuses.ts b/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 @@
1import * as express from 'express' 1import * as express from 'express'
2import 'express-validator' 2import 'express-validator'
3import { body, param } from 'express-validator/check' 3import { body, param } from 'express-validator/check'
4import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' 4import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
5import { isVideoExist } from '../../helpers/custom-validators/videos' 5import { isVideoExist } from '../../../helpers/custom-validators/videos'
6import { logger } from '../../helpers/logger' 6import { logger } from '../../../helpers/logger'
7import { areValidationErrors } from './utils' 7import { areValidationErrors } from '../utils'
8import { 8import {
9 isVideoAbuseExist, 9 isVideoAbuseExist,
10 isVideoAbuseModerationCommentValid, 10 isVideoAbuseModerationCommentValid,
11 isVideoAbuseReasonValid, 11 isVideoAbuseReasonValid,
12 isVideoAbuseStateValid 12 isVideoAbuseStateValid
13} from '../../helpers/custom-validators/video-abuses' 13} from '../../../helpers/custom-validators/video-abuses'
14 14
15const videoAbuseReportValidator = [ 15const videoAbuseReportValidator = [
16 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 16 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
index 95a2b9f17..13da7acff 100644
--- a/server/middlewares/validators/video-blacklist.ts
+++ b/server/middlewares/validators/videos/video-blacklist.ts
@@ -1,10 +1,10 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator/check' 2import { body, param } from 'express-validator/check'
3import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 3import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
4import { isVideoExist } from '../../helpers/custom-validators/videos' 4import { isVideoExist } from '../../../helpers/custom-validators/videos'
5import { logger } from '../../helpers/logger' 5import { logger } from '../../../helpers/logger'
6import { areValidationErrors } from './utils' 6import { areValidationErrors } from '../utils'
7import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 7import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist'
8 8
9const videosBlacklistRemoveValidator = [ 9const videosBlacklistRemoveValidator = [
10 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 10 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
index 51ffd7f3c..63d84fbec 100644
--- a/server/middlewares/validators/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -1,13 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { areValidationErrors } from './utils' 2import { areValidationErrors } from '../utils'
3import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos' 3import { checkUserCanManageVideo, isVideoExist } from '../../../helpers/custom-validators/videos'
4import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 4import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
5import { body, param } from 'express-validator/check' 5import { body, param } from 'express-validator/check'
6import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { CONSTRAINTS_FIELDS } from '../../../initializers'
7import { UserRight } from '../../../shared' 7import { UserRight } from '../../../../shared'
8import { logger } from '../../helpers/logger' 8import { logger } from '../../../helpers/logger'
9import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 9import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
10import { cleanUpReqFiles } from '../../helpers/express-utils' 10import { cleanUpReqFiles } from '../../../helpers/express-utils'
11 11
12const addVideoCaptionValidator = [ 12const addVideoCaptionValidator = [
13 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 13 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
index 56a347b39..f039794e0 100644
--- a/server/middlewares/validators/video-channels.ts
+++ b/server/middlewares/validators/videos/video-channels.ts
@@ -1,20 +1,20 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator/check' 2import { body, param } from 'express-validator/check'
3import { UserRight } from '../../../shared' 3import { UserRight } from '../../../../shared'
4import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' 4import { isAccountNameWithHostExist } from '../../../helpers/custom-validators/accounts'
5import { 5import {
6 isLocalVideoChannelNameExist, 6 isLocalVideoChannelNameExist,
7 isVideoChannelDescriptionValid, 7 isVideoChannelDescriptionValid,
8 isVideoChannelNameValid, 8 isVideoChannelNameValid,
9 isVideoChannelNameWithHostExist, 9 isVideoChannelNameWithHostExist,
10 isVideoChannelSupportValid 10 isVideoChannelSupportValid
11} from '../../helpers/custom-validators/video-channels' 11} from '../../../helpers/custom-validators/video-channels'
12import { logger } from '../../helpers/logger' 12import { logger } from '../../../helpers/logger'
13import { UserModel } from '../../models/account/user' 13import { UserModel } from '../../../models/account/user'
14import { VideoChannelModel } from '../../models/video/video-channel' 14import { VideoChannelModel } from '../../../models/video/video-channel'
15import { areValidationErrors } from './utils' 15import { areValidationErrors } from '../utils'
16import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' 16import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
17import { ActorModel } from '../../models/activitypub/actor' 17import { ActorModel } from '../../../models/activitypub/actor'
18 18
19const listVideoAccountChannelsValidator = [ 19const listVideoAccountChannelsValidator = [
20 param('accountName').exists().withMessage('Should have a valid account name'), 20 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
index 693852499..348d33082 100644
--- a/server/middlewares/validators/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -1,14 +1,14 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator/check' 2import { body, param } from 'express-validator/check'
3import { UserRight } from '../../../shared' 3import { UserRight } from '../../../../shared'
4import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' 4import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
5import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' 5import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments'
6import { isVideoExist } from '../../helpers/custom-validators/videos' 6import { isVideoExist } from '../../../helpers/custom-validators/videos'
7import { logger } from '../../helpers/logger' 7import { logger } from '../../../helpers/logger'
8import { UserModel } from '../../models/account/user' 8import { UserModel } from '../../../models/account/user'
9import { VideoModel } from '../../models/video/video' 9import { VideoModel } from '../../../models/video/video'
10import { VideoCommentModel } from '../../models/video/video-comment' 10import { VideoCommentModel } from '../../../models/video/video-comment'
11import { areValidationErrors } from './utils' 11import { areValidationErrors } from '../utils'
12 12
13const listVideoCommentThreadsValidator = [ 13const listVideoCommentThreadsValidator = [
14 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 14 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
index b2063b8da..48d20f904 100644
--- a/server/middlewares/validators/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -1,14 +1,14 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator/check' 2import { body } from 'express-validator/check'
3import { isIdValid } from '../../helpers/custom-validators/misc' 3import { isIdValid } from '../../../helpers/custom-validators/misc'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from '../utils'
6import { getCommonVideoAttributes } from './videos' 6import { getCommonVideoAttributes } from './videos'
7import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../helpers/custom-validators/video-imports' 7import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
8import { cleanUpReqFiles } from '../../helpers/express-utils' 8import { cleanUpReqFiles } from '../../../helpers/express-utils'
9import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos' 9import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
10import { CONFIG } from '../../initializers/constants' 10import { CONFIG } from '../../../initializers/constants'
11import { CONSTRAINTS_FIELDS } from '../../initializers' 11import { CONSTRAINTS_FIELDS } from '../../../initializers'
12 12
13const videoImportAddValidator = getCommonVideoAttributes().concat([ 13const videoImportAddValidator = getCommonVideoAttributes().concat([
14 body('channelId') 14 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 @@
1import { body, param } from 'express-validator/check'
2import * as express from 'express'
3import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
4import { isVideoExist } from '../../../helpers/custom-validators/videos'
5import { areValidationErrors } from '../utils'
6import { logger } from '../../../helpers/logger'
7
8const videoWatchingValidator = [
9 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
10 body('currentTime')
11 .toInt()
12 .isInt().withMessage('Should have correct current time'),
13
14 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
15 logger.debug('Checking videoWatching parameters', { parameters: req.body })
16
17 if (areValidationErrors(req, res)) return
18 if (!await isVideoExist(req.params.videoId, res, 'id')) return
19
20 return next()
21 }
22]
23
24// ---------------------------------------------------------------------------
25
26export {
27 videoWatchingValidator
28}
diff --git a/server/middlewares/validators/videos.ts b/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 @@
1import * as express from 'express' 1import * as express from 'express'
2import 'express-validator' 2import 'express-validator'
3import { body, param, ValidationChain } from 'express-validator/check' 3import { body, param, ValidationChain } from 'express-validator/check'
4import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../shared' 4import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
5import { 5import {
6 isBooleanValid, 6 isBooleanValid,
7 isDateValid, 7 isDateValid,
@@ -10,7 +10,7 @@ import {
10 isUUIDValid, 10 isUUIDValid,
11 toIntOrNull, 11 toIntOrNull,
12 toValueOrNull 12 toValueOrNull
13} from '../../helpers/custom-validators/misc' 13} from '../../../helpers/custom-validators/misc'
14import { 14import {
15 checkUserCanManageVideo, 15 checkUserCanManageVideo,
16 isScheduleVideoUpdatePrivacyValid, 16 isScheduleVideoUpdatePrivacyValid,
@@ -27,21 +27,21 @@ import {
27 isVideoRatingTypeValid, 27 isVideoRatingTypeValid,
28 isVideoSupportValid, 28 isVideoSupportValid,
29 isVideoTagsValid 29 isVideoTagsValid
30} from '../../helpers/custom-validators/videos' 30} from '../../../helpers/custom-validators/videos'
31import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' 31import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
32import { logger } from '../../helpers/logger' 32import { logger } from '../../../helpers/logger'
33import { CONSTRAINTS_FIELDS } from '../../initializers' 33import { CONSTRAINTS_FIELDS } from '../../../initializers'
34import { VideoShareModel } from '../../models/video/video-share' 34import { VideoShareModel } from '../../../models/video/video-share'
35import { authenticate } from '../oauth' 35import { authenticate } from '../../oauth'
36import { areValidationErrors } from './utils' 36import { areValidationErrors } from '../utils'
37import { cleanUpReqFiles } from '../../helpers/express-utils' 37import { cleanUpReqFiles } from '../../../helpers/express-utils'
38import { VideoModel } from '../../models/video/video' 38import { VideoModel } from '../../../models/video/video'
39import { UserModel } from '../../models/account/user' 39import { UserModel } from '../../../models/account/user'
40import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../helpers/custom-validators/video-ownership' 40import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
41import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model' 41import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
42import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' 42import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership'
43import { AccountModel } from '../../models/account/account' 43import { AccountModel } from '../../../models/account/account'
44import { VideoFetchType } from '../../helpers/video' 44import { VideoFetchType } from '../../../helpers/video'
45 45
46const videosAddValidator = getCommonVideoAttributes().concat([ 46const videosAddValidator = getCommonVideoAttributes().concat([
47 body('videofile') 47 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 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from '../video/video'
3import { UserModel } from './user'
4
5@Table({
6 tableName: 'userVideoHistory',
7 indexes: [
8 {
9 fields: [ 'userId', 'videoId' ],
10 unique: true
11 },
12 {
13 fields: [ 'userId' ]
14 },
15 {
16 fields: [ 'videoId' ]
17 }
18 ]
19})
20export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
21 @CreatedAt
22 createdAt: Date
23
24 @UpdatedAt
25 updatedAt: Date
26
27 @AllowNull(false)
28 @IsInt
29 @Column
30 currentTime: number
31
32 @ForeignKey(() => VideoModel)
33 @Column
34 videoId: number
35
36 @BelongsTo(() => VideoModel, {
37 foreignKey: {
38 allowNull: false
39 },
40 onDelete: 'CASCADE'
41 })
42 Video: VideoModel
43
44 @ForeignKey(() => UserModel)
45 @Column
46 userId: number
47
48 @BelongsTo(() => UserModel, {
49 foreignKey: {
50 allowNull: false
51 },
52 onDelete: 'CASCADE'
53 })
54 User: UserModel
55}
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 {
10 getVideoLikesActivityPubUrl, 10 getVideoLikesActivityPubUrl,
11 getVideoSharesActivityPubUrl 11 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub' 12} from '../../lib/activitypub'
13import { isArray } from 'util'
13 14
14export type VideoFormattingJSONOptions = { 15export type VideoFormattingJSONOptions = {
15 completeDescription?: boolean 16 completeDescription?: boolean
@@ -24,6 +25,8 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
24 const formattedAccount = video.VideoChannel.Account.toFormattedJSON() 25 const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
25 const formattedVideoChannel = video.VideoChannel.toFormattedJSON() 26 const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
26 27
28 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
29
27 const videoObject: Video = { 30 const videoObject: Video = {
28 id: video.id, 31 id: video.id,
29 uuid: video.uuid, 32 uuid: video.uuid,
@@ -74,7 +77,11 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
74 url: formattedVideoChannel.url, 77 url: formattedVideoChannel.url,
75 host: formattedVideoChannel.host, 78 host: formattedVideoChannel.host,
76 avatar: formattedVideoChannel.avatar 79 avatar: formattedVideoChannel.avatar
77 } 80 },
81
82 userHistory: userHistory ? {
83 currentTime: userHistory.currentTime
84 } : undefined
78 } 85 }
79 86
80 if (options) { 87 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 {
92 videoModelToFormattedJSON 92 videoModelToFormattedJSON
93} from './video-format-utils' 93} from './video-format-utils'
94import * as validator from 'validator' 94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history'
96
95 97
96// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 98// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
97const indexes: Sequelize.DefineIndexesOptions[] = [ 99const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -127,7 +129,8 @@ export enum ScopeNames {
127 WITH_TAGS = 'WITH_TAGS', 129 WITH_TAGS = 'WITH_TAGS',
128 WITH_FILES = 'WITH_FILES', 130 WITH_FILES = 'WITH_FILES',
129 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 131 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
130 WITH_BLACKLISTED = 'WITH_BLACKLISTED' 132 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
133 WITH_USER_HISTORY = 'WITH_USER_HISTORY'
131} 134}
132 135
133type ForAPIOptions = { 136type ForAPIOptions = {
@@ -464,6 +467,8 @@ type AvailableForListIDsOptions = {
464 include: [ 467 include: [
465 { 468 {
466 model: () => VideoFileModel.unscoped(), 469 model: () => VideoFileModel.unscoped(),
470 // FIXME: typings
471 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
467 required: false, 472 required: false,
468 include: [ 473 include: [
469 { 474 {
@@ -482,6 +487,20 @@ type AvailableForListIDsOptions = {
482 required: false 487 required: false
483 } 488 }
484 ] 489 ]
490 },
491 [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => {
492 return {
493 include: [
494 {
495 attributes: [ 'currentTime' ],
496 model: UserVideoHistoryModel.unscoped(),
497 required: false,
498 where: {
499 userId
500 }
501 }
502 ]
503 }
485 } 504 }
486}) 505})
487@Table({ 506@Table({
@@ -672,11 +691,19 @@ export class VideoModel extends Model<VideoModel> {
672 name: 'videoId', 691 name: 'videoId',
673 allowNull: false 692 allowNull: false
674 }, 693 },
675 onDelete: 'cascade', 694 onDelete: 'cascade'
676 hooks: true
677 }) 695 })
678 VideoViews: VideoViewModel[] 696 VideoViews: VideoViewModel[]
679 697
698 @HasMany(() => UserVideoHistoryModel, {
699 foreignKey: {
700 name: 'videoId',
701 allowNull: false
702 },
703 onDelete: 'cascade'
704 })
705 UserVideoHistories: UserVideoHistoryModel[]
706
680 @HasOne(() => ScheduleVideoUpdateModel, { 707 @HasOne(() => ScheduleVideoUpdateModel, {
681 foreignKey: { 708 foreignKey: {
682 name: 'videoId', 709 name: 'videoId',
@@ -930,7 +957,8 @@ export class VideoModel extends Model<VideoModel> {
930 accountId?: number, 957 accountId?: number,
931 videoChannelId?: number, 958 videoChannelId?: number,
932 actorId?: number 959 actorId?: number
933 trendingDays?: number 960 trendingDays?: number,
961 userId?: number
934 }, countVideos = true) { 962 }, countVideos = true) {
935 const query: IFindOptions<VideoModel> = { 963 const query: IFindOptions<VideoModel> = {
936 offset: options.start, 964 offset: options.start,
@@ -961,6 +989,7 @@ export class VideoModel extends Model<VideoModel> {
961 accountId: options.accountId, 989 accountId: options.accountId,
962 videoChannelId: options.videoChannelId, 990 videoChannelId: options.videoChannelId,
963 includeLocalVideos: options.includeLocalVideos, 991 includeLocalVideos: options.includeLocalVideos,
992 userId: options.userId,
964 trendingDays 993 trendingDays
965 } 994 }
966 995
@@ -983,6 +1012,7 @@ export class VideoModel extends Model<VideoModel> {
983 tagsAllOf?: string[] 1012 tagsAllOf?: string[]
984 durationMin?: number // seconds 1013 durationMin?: number // seconds
985 durationMax?: number // seconds 1014 durationMax?: number // seconds
1015 userId?: number
986 }) { 1016 }) {
987 const whereAnd = [] 1017 const whereAnd = []
988 1018
@@ -1058,7 +1088,8 @@ export class VideoModel extends Model<VideoModel> {
1058 licenceOneOf: options.licenceOneOf, 1088 licenceOneOf: options.licenceOneOf,
1059 languageOneOf: options.languageOneOf, 1089 languageOneOf: options.languageOneOf,
1060 tagsOneOf: options.tagsOneOf, 1090 tagsOneOf: options.tagsOneOf,
1061 tagsAllOf: options.tagsAllOf 1091 tagsAllOf: options.tagsAllOf,
1092 userId: options.userId
1062 } 1093 }
1063 1094
1064 return VideoModel.getAvailableForApi(query, queryOptions) 1095 return VideoModel.getAvailableForApi(query, queryOptions)
@@ -1125,7 +1156,7 @@ export class VideoModel extends Model<VideoModel> {
1125 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1156 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
1126 } 1157 }
1127 1158
1128 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) { 1159 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1129 const where = VideoModel.buildWhereIdOrUUID(id) 1160 const where = VideoModel.buildWhereIdOrUUID(id)
1130 1161
1131 const options = { 1162 const options = {
@@ -1134,14 +1165,20 @@ export class VideoModel extends Model<VideoModel> {
1134 transaction: t 1165 transaction: t
1135 } 1166 }
1136 1167
1168 const scopes = [
1169 ScopeNames.WITH_TAGS,
1170 ScopeNames.WITH_BLACKLISTED,
1171 ScopeNames.WITH_FILES,
1172 ScopeNames.WITH_ACCOUNT_DETAILS,
1173 ScopeNames.WITH_SCHEDULED_UPDATE
1174 ]
1175
1176 if (userId) {
1177 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
1178 }
1179
1137 return VideoModel 1180 return VideoModel
1138 .scope([ 1181 .scope(scopes)
1139 ScopeNames.WITH_TAGS,
1140 ScopeNames.WITH_BLACKLISTED,
1141 ScopeNames.WITH_FILES,
1142 ScopeNames.WITH_ACCOUNT_DETAILS,
1143 ScopeNames.WITH_SCHEDULED_UPDATE
1144 ])
1145 .findOne(options) 1182 .findOne(options)
1146 } 1183 }
1147 1184
@@ -1225,7 +1262,11 @@ export class VideoModel extends Model<VideoModel> {
1225 return {} 1262 return {}
1226 } 1263 }
1227 1264
1228 private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) { 1265 private static async getAvailableForApi (
1266 query: IFindOptions<VideoModel>,
1267 options: AvailableForListIDsOptions & { userId?: number},
1268 countVideos = true
1269 ) {
1229 const idsScope = { 1270 const idsScope = {
1230 method: [ 1271 method: [
1231 ScopeNames.AVAILABLE_FOR_LIST_IDS, options 1272 ScopeNames.AVAILABLE_FOR_LIST_IDS, options
@@ -1249,8 +1290,15 @@ export class VideoModel extends Model<VideoModel> {
1249 1290
1250 if (ids.length === 0) return { data: [], total: count } 1291 if (ids.length === 0) return { data: [], total: count }
1251 1292
1252 const apiScope = { 1293 // FIXME: typings
1253 method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] 1294 const apiScope: any[] = [
1295 {
1296 method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ]
1297 }
1298 ]
1299
1300 if (options.userId) {
1301 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] })
1254 } 1302 }
1255 1303
1256 const secondQuery = { 1304 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'
15import './video-comments' 15import './video-comments'
16import './video-imports' 16import './video-imports'
17import './videos' 17import './videos'
18import './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 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 flushTests,
7 killallServers,
8 makePostBodyRequest,
9 makePutBodyRequest,
10 runServer,
11 ServerInfo,
12 setAccessTokensToServers,
13 uploadVideo
14} from '../../utils'
15
16const expect = chai.expect
17
18describe('Test videos history API validator', function () {
19 let path: string
20 let server: ServerInfo
21
22 // ---------------------------------------------------------------
23
24 before(async function () {
25 this.timeout(30000)
26
27 await flushTests()
28
29 server = await runServer(1)
30
31 await setAccessTokensToServers([ server ])
32
33 const res = await uploadVideo(server.url, server.accessToken, {})
34 const videoUUID = res.body.video.uuid
35
36 path = '/api/v1/videos/' + videoUUID + '/watching'
37 })
38
39 describe('When notifying a user is watching a video', function () {
40
41 it('Should fail with an unauthenticated user', async function () {
42 const fields = { currentTime: 5 }
43 await makePutBodyRequest({ url: server.url, path, fields, statusCodeExpected: 401 })
44 })
45
46 it('Should fail with an incorrect video id', async function () {
47 const fields = { currentTime: 5 }
48 const path = '/api/v1/videos/blabla/watching'
49 await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 })
50 })
51
52 it('Should fail with an unknown video', async function () {
53 const fields = { currentTime: 5 }
54 const path = '/api/v1/videos/d91fff41-c24d-4508-8e13-3bd5902c3b02/watching'
55
56 await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 404 })
57 })
58
59 it('Should fail with a bad current time', async function () {
60 const fields = { currentTime: 'hello' }
61 await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 })
62 })
63
64 it('Should succeed with the correct parameters', async function () {
65 const fields = { currentTime: 5 }
66
67 await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 204 })
68 })
69 })
70
71 after(async function () {
72 killallServers([ server ])
73
74 // Keep the logs if the test failed
75 if (this['ok']) {
76 await flushTests()
77 }
78 })
79})
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'
14import './video-privacy' 14import './video-privacy'
15import './video-schedule-update' 15import './video-schedule-update'
16import './video-transcoder' 16import './video-transcoder'
17import './videos-history'
17import './videos-overview' 18import './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 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 flushTests,
7 getVideosListWithToken,
8 getVideoWithToken,
9 killallServers, makePutBodyRequest,
10 runServer, searchVideoWithToken,
11 ServerInfo,
12 setAccessTokensToServers,
13 uploadVideo
14} from '../../utils'
15import { Video, VideoDetails } from '../../../../shared/models/videos'
16import { userWatchVideo } from '../../utils/videos/video-history'
17
18const expect = chai.expect
19
20describe('Test videos history', function () {
21 let server: ServerInfo = null
22 let video1UUID: string
23 let video2UUID: string
24 let video3UUID: string
25
26 before(async function () {
27 this.timeout(30000)
28
29 await flushTests()
30
31 server = await runServer(1)
32
33 await setAccessTokensToServers([ server ])
34
35 {
36 const res = await uploadVideo(server.url, server.accessToken, { name: 'video 1' })
37 video1UUID = res.body.video.uuid
38 }
39
40 {
41 const res = await uploadVideo(server.url, server.accessToken, { name: 'video 2' })
42 video2UUID = res.body.video.uuid
43 }
44
45 {
46 const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' })
47 video3UUID = res.body.video.uuid
48 }
49 })
50
51 it('Should get videos, without watching history', async function () {
52 const res = await getVideosListWithToken(server.url, server.accessToken)
53 const videos: Video[] = res.body.data
54
55 for (const video of videos) {
56 const resDetail = await getVideoWithToken(server.url, server.accessToken, video.id)
57 const videoDetails: VideoDetails = resDetail.body
58
59 expect(video.userHistory).to.be.undefined
60 expect(videoDetails.userHistory).to.be.undefined
61 }
62 })
63
64 it('Should watch the first and second video', async function () {
65 await userWatchVideo(server.url, server.accessToken, video1UUID, 3)
66 await userWatchVideo(server.url, server.accessToken, video2UUID, 8)
67 })
68
69 it('Should return the correct history when listing, searching and getting videos', async function () {
70 const videosOfVideos: Video[][] = []
71
72 {
73 const res = await getVideosListWithToken(server.url, server.accessToken)
74 videosOfVideos.push(res.body.data)
75 }
76
77 {
78 const res = await searchVideoWithToken(server.url, 'video', server.accessToken)
79 videosOfVideos.push(res.body.data)
80 }
81
82 for (const videos of videosOfVideos) {
83 const video1 = videos.find(v => v.uuid === video1UUID)
84 const video2 = videos.find(v => v.uuid === video2UUID)
85 const video3 = videos.find(v => v.uuid === video3UUID)
86
87 expect(video1.userHistory).to.not.be.undefined
88 expect(video1.userHistory.currentTime).to.equal(3)
89
90 expect(video2.userHistory).to.not.be.undefined
91 expect(video2.userHistory.currentTime).to.equal(8)
92
93 expect(video3.userHistory).to.be.undefined
94 }
95
96 {
97 const resDetail = await getVideoWithToken(server.url, server.accessToken, video1UUID)
98 const videoDetails: VideoDetails = resDetail.body
99
100 expect(videoDetails.userHistory).to.not.be.undefined
101 expect(videoDetails.userHistory.currentTime).to.equal(3)
102 }
103
104 {
105 const resDetail = await getVideoWithToken(server.url, server.accessToken, video2UUID)
106 const videoDetails: VideoDetails = resDetail.body
107
108 expect(videoDetails.userHistory).to.not.be.undefined
109 expect(videoDetails.userHistory.currentTime).to.equal(8)
110 }
111
112 {
113 const resDetail = await getVideoWithToken(server.url, server.accessToken, video3UUID)
114 const videoDetails: VideoDetails = resDetail.body
115
116 expect(videoDetails.userHistory).to.be.undefined
117 }
118 })
119
120 after(async function () {
121 killallServers([ server ])
122
123 // Keep the logs if the test failed
124 if (this['ok']) {
125 await flushTests()
126 }
127 })
128})
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 @@
1import { makePutBodyRequest } from '../requests/requests'
2
3function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number) {
4 const path = '/api/v1/videos/' + videoId + '/watching'
5 const fields = { currentTime }
6
7 return makePutBodyRequest({ url, path, token, fields, statusCodeExpected: 204 })
8}
9
10// ---------------------------------------------------------------------------
11
12export {
13 userWatchVideo
14}
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'
7export * from './user-right.enum' 7export * from './user-right.enum'
8export * from './user-role' 8export * from './user-role'
9export * from './user-video-quota.model' 9export * from './user-video-quota.model'
10export * 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 @@
1export interface UserWatchingVideo {
2 currentTime: number
3}
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 {
68 68
69 account: AccountAttribute 69 account: AccountAttribute
70 channel: VideoChannelAttribute 70 channel: VideoChannelAttribute
71
72 userHistory?: {
73 currentTime: number
74 }
71} 75}
72 76
73export interface VideoDetails extends Video { 77export interface VideoDetails extends Video {