aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/helpers/query.ts6
-rw-r--r--server/middlewares/validators/videos/videos.ts11
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts22
-rw-r--r--server/models/video/video.ts10
-rw-r--r--server/tests/api/check-params/videos-common-filters.ts19
-rw-r--r--server/tests/api/videos/videos-common-filters.ts32
6 files changed, 93 insertions, 7 deletions
diff --git a/server/helpers/query.ts b/server/helpers/query.ts
index 1142d02e4..10efae41c 100644
--- a/server/helpers/query.ts
+++ b/server/helpers/query.ts
@@ -24,7 +24,8 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
24 'skipCount', 24 'skipCount',
25 'hasHLSFiles', 25 'hasHLSFiles',
26 'hasWebtorrentFiles', 26 'hasWebtorrentFiles',
27 'search' 27 'search',
28 'excludeAlreadyWatched'
28 ]) 29 ])
29} 30}
30 31
@@ -41,7 +42,8 @@ function pickSearchVideoQuery (query: VideosSearchQueryAfterSanitize) {
41 'originallyPublishedEndDate', 42 'originallyPublishedEndDate',
42 'durationMin', 43 'durationMin',
43 'durationMax', 44 'durationMax',
44 'uuids' 45 'uuids',
46 'excludeAlreadyWatched'
45 ]) 47 ])
46 } 48 }
47} 49}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index e29eb4a32..ea6bd0721 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -489,6 +489,10 @@ const commonVideosFiltersValidator = [
489 query('search') 489 query('search')
490 .optional() 490 .optional()
491 .custom(exists), 491 .custom(exists),
492 query('excludeAlreadyWatched')
493 .optional()
494 .customSanitizer(toBooleanOrNull)
495 .isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
492 496
493 (req: express.Request, res: express.Response, next: express.NextFunction) => { 497 (req: express.Request, res: express.Response, next: express.NextFunction) => {
494 if (areValidationErrors(req, res)) return 498 if (areValidationErrors(req, res)) return
@@ -520,6 +524,13 @@ const commonVideosFiltersValidator = [
520 } 524 }
521 } 525 }
522 526
527 if (!user && exists(req.query.excludeAlreadyWatched)) {
528 res.fail({
529 status: HttpStatusCode.BAD_REQUEST_400,
530 message: 'Cannot use excludeAlreadyWatched parameter when auth token is not provided'
531 })
532 return false
533 }
523 return next() 534 return next()
524 } 535 }
525] 536]
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index 62f1855c7..cba77c1d1 100644
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -78,6 +78,8 @@ export type BuildVideosListQueryOptions = {
78 78
79 transaction?: Transaction 79 transaction?: Transaction
80 logging?: boolean 80 logging?: boolean
81
82 excludeAlreadyWatched?: boolean
81} 83}
82 84
83export class VideosIdListQueryBuilder extends AbstractRunQuery { 85export class VideosIdListQueryBuilder extends AbstractRunQuery {
@@ -260,6 +262,14 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
260 this.whereDurationMax(options.durationMax) 262 this.whereDurationMax(options.durationMax)
261 } 263 }
262 264
265 if (options.excludeAlreadyWatched) {
266 if (exists(options.user.id)) {
267 this.whereExcludeAlreadyWatched(options.user.id)
268 } else {
269 throw new Error('Cannot use excludeAlreadyWatched parameter when auth token is not provided')
270 }
271 }
272
263 this.whereSearch(options.search) 273 this.whereSearch(options.search)
264 274
265 if (options.isCount === true) { 275 if (options.isCount === true) {
@@ -598,6 +608,18 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
598 this.replacements.durationMax = durationMax 608 this.replacements.durationMax = durationMax
599 } 609 }
600 610
611 private whereExcludeAlreadyWatched (userId: number) {
612 this.and.push(
613 'NOT EXISTS (' +
614 ' SELECT 1' +
615 ' FROM "userVideoHistory"' +
616 ' WHERE "video"."id" = "userVideoHistory"."videoId"' +
617 ' AND "userVideoHistory"."userId" = :excludeAlreadyWatchedUserId' +
618 ')'
619 )
620 this.replacements.excludeAlreadyWatchedUserId = userId
621 }
622
601 private groupForTrending (trendingDays: number) { 623 private groupForTrending (trendingDays: number) {
602 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) 624 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
603 625
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 0c5ed64ec..f817c4a33 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1086,6 +1086,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1086 countVideos?: boolean 1086 countVideos?: boolean
1087 1087
1088 search?: string 1088 search?: string
1089
1090 excludeAlreadyWatched?: boolean
1089 }) { 1091 }) {
1090 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) 1092 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1091 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) 1093 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@@ -1124,7 +1126,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1124 'historyOfUser', 1126 'historyOfUser',
1125 'hasHLSFiles', 1127 'hasHLSFiles',
1126 'hasWebtorrentFiles', 1128 'hasWebtorrentFiles',
1127 'search' 1129 'search',
1130 'excludeAlreadyWatched'
1128 ]), 1131 ]),
1129 1132
1130 serverAccountIdForBlock: serverActor.Account.id, 1133 serverAccountIdForBlock: serverActor.Account.id,
@@ -1170,6 +1173,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1170 durationMin?: number // seconds 1173 durationMin?: number // seconds
1171 durationMax?: number // seconds 1174 durationMax?: number // seconds
1172 uuids?: string[] 1175 uuids?: string[]
1176
1177 excludeAlreadyWatched?: boolean
1173 }) { 1178 }) {
1174 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) 1179 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1175 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) 1180 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@@ -1203,7 +1208,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1203 'hasWebtorrentFiles', 1208 'hasWebtorrentFiles',
1204 'uuids', 1209 'uuids',
1205 'search', 1210 'search',
1206 'displayOnlyForFollower' 1211 'displayOnlyForFollower',
1212 'excludeAlreadyWatched'
1207 ]), 1213 ]),
1208 serverAccountIdForBlock: serverActor.Account.id 1214 serverAccountIdForBlock: serverActor.Account.id
1209 } 1215 }
diff --git a/server/tests/api/check-params/videos-common-filters.ts b/server/tests/api/check-params/videos-common-filters.ts
index 11d9fd95b..3e44e2f67 100644
--- a/server/tests/api/check-params/videos-common-filters.ts
+++ b/server/tests/api/check-params/videos-common-filters.ts
@@ -122,6 +122,8 @@ describe('Test video filters validators', function () {
122 include?: VideoInclude 122 include?: VideoInclude
123 privacyOneOf?: VideoPrivacy[] 123 privacyOneOf?: VideoPrivacy[]
124 expectedStatus: HttpStatusCode 124 expectedStatus: HttpStatusCode
125 excludeAlreadyWatched?: boolean
126 unauthenticatedUser?: boolean
125 }) { 127 }) {
126 const paths = [ 128 const paths = [
127 '/api/v1/video-channels/root_channel/videos', 129 '/api/v1/video-channels/root_channel/videos',
@@ -131,14 +133,19 @@ describe('Test video filters validators', function () {
131 ] 133 ]
132 134
133 for (const path of paths) { 135 for (const path of paths) {
136 const token = options.unauthenticatedUser
137 ? undefined
138 : options.token || server.accessToken
139
134 await makeGetRequest({ 140 await makeGetRequest({
135 url: server.url, 141 url: server.url,
136 path, 142 path,
137 token: options.token || server.accessToken, 143 token,
138 query: { 144 query: {
139 isLocal: options.isLocal, 145 isLocal: options.isLocal,
140 privacyOneOf: options.privacyOneOf, 146 privacyOneOf: options.privacyOneOf,
141 include: options.include 147 include: options.include,
148 excludeAlreadyWatched: options.excludeAlreadyWatched
142 }, 149 },
143 expectedStatus: options.expectedStatus 150 expectedStatus: options.expectedStatus
144 }) 151 })
@@ -213,6 +220,14 @@ describe('Test video filters validators', function () {
213 } 220 }
214 }) 221 })
215 }) 222 })
223
224 it('Should fail when trying to exclude already watched videos for an unlogged user', async function () {
225 await testEndpoints({ excludeAlreadyWatched: true, unauthenticatedUser: true, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
226 })
227
228 it('Should succeed when trying to exclude already watched videos for a logged user', async function () {
229 await testEndpoints({ token: userAccessToken, excludeAlreadyWatched: true, expectedStatus: HttpStatusCode.OK_200 })
230 })
216 }) 231 })
217 232
218 after(async function () { 233 after(async function () {
diff --git a/server/tests/api/videos/videos-common-filters.ts b/server/tests/api/videos/videos-common-filters.ts
index 1ab78ac49..30251706b 100644
--- a/server/tests/api/videos/videos-common-filters.ts
+++ b/server/tests/api/videos/videos-common-filters.ts
@@ -162,13 +162,23 @@ describe('Test videos filter', function () {
162 tagsAllOf?: string[] 162 tagsAllOf?: string[]
163 token?: string 163 token?: string
164 expectedStatus?: HttpStatusCode 164 expectedStatus?: HttpStatusCode
165 excludeAlreadyWatched?: boolean
165 }) { 166 }) {
166 const res = await makeGetRequest({ 167 const res = await makeGetRequest({
167 url: options.server.url, 168 url: options.server.url,
168 path: options.path, 169 path: options.path,
169 token: options.token ?? options.server.accessToken, 170 token: options.token ?? options.server.accessToken,
170 query: { 171 query: {
171 ...pick(options, [ 'isLocal', 'include', 'category', 'tagsAllOf', 'hasWebtorrentFiles', 'hasHLSFiles', 'privacyOneOf' ]), 172 ...pick(options, [
173 'isLocal',
174 'include',
175 'category',
176 'tagsAllOf',
177 'hasWebtorrentFiles',
178 'hasHLSFiles',
179 'privacyOneOf',
180 'excludeAlreadyWatched'
181 ]),
172 182
173 sort: 'createdAt' 183 sort: 'createdAt'
174 }, 184 },
@@ -187,6 +197,7 @@ describe('Test videos filter', function () {
187 token?: string 197 token?: string
188 expectedStatus?: HttpStatusCode 198 expectedStatus?: HttpStatusCode
189 skipSubscription?: boolean 199 skipSubscription?: boolean
200 excludeAlreadyWatched?: boolean
190 } 201 }
191 ) { 202 ) {
192 const { skipSubscription = false } = options 203 const { skipSubscription = false } = options
@@ -525,6 +536,25 @@ describe('Test videos filter', function () {
525 } 536 }
526 } 537 }
527 }) 538 })
539
540 it('Should filter already watched videos by the user', async function () {
541 const { id } = await servers[0].videos.upload({ attributes: { name: 'video for history' } })
542
543 for (const path of paths) {
544 const videos = await listVideos({ server: servers[0], path, isLocal: true, excludeAlreadyWatched: true })
545 const foundVideo = videos.find(video => video.id === id)
546
547 expect(foundVideo).to.not.be.undefined
548 }
549 await servers[0].views.view({ id, token: servers[0].accessToken })
550
551 for (const path of paths) {
552 const videos = await listVideos({ server: servers[0], path, excludeAlreadyWatched: true })
553 const foundVideo = videos.find(video => video.id === id)
554
555 expect(foundVideo).to.be.undefined
556 }
557 })
528 }) 558 })
529 559
530 after(async function () { 560 after(async function () {