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 {