aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts3
-rw-r--r--client/src/app/shared/shared-search/advanced-search.model.ts12
-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
-rw-r--r--shared/models/search/videos-common-query.model.ts2
-rw-r--r--support/doc/api/openapi.yaml11
10 files changed, 119 insertions, 9 deletions
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts
index 4654da847..b0ae910ac 100644
--- a/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts
@@ -63,6 +63,9 @@ export class RecentVideosRecommendationService implements RecommendationService
63 searchTarget: 'local', 63 searchTarget: 'local',
64 nsfw: user.nsfwPolicy 64 nsfw: user.nsfwPolicy
65 ? this.videos.nsfwPolicyToParam(user.nsfwPolicy) 65 ? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
66 : undefined,
67 excludeAlreadyWatched: user.id
68 ? true
66 : undefined 69 : undefined
67 }) 70 })
68 } 71 }
diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts
index e8bb00fd3..29fe3e8dc 100644
--- a/client/src/app/shared/shared-search/advanced-search.model.ts
+++ b/client/src/app/shared/shared-search/advanced-search.model.ts
@@ -40,6 +40,8 @@ export class AdvancedSearch {
40 searchTarget: SearchTargetType 40 searchTarget: SearchTargetType
41 resultType: AdvancedSearchResultType 41 resultType: AdvancedSearchResultType
42 42
43 excludeAlreadyWatched?: boolean
44
43 constructor (options?: { 45 constructor (options?: {
44 startDate?: string 46 startDate?: string
45 endDate?: string 47 endDate?: string
@@ -62,6 +64,8 @@ export class AdvancedSearch {
62 sort?: string 64 sort?: string
63 searchTarget?: SearchTargetType 65 searchTarget?: SearchTargetType
64 resultType?: AdvancedSearchResultType 66 resultType?: AdvancedSearchResultType
67
68 excludeAlreadyWatched?: boolean
65 }) { 69 }) {
66 if (!options) return 70 if (!options) return
67 71
@@ -87,6 +91,8 @@ export class AdvancedSearch {
87 91
88 this.resultType = options.resultType || undefined 92 this.resultType = options.resultType || undefined
89 93
94 this.excludeAlreadyWatched = options.excludeAlreadyWatched || undefined
95
90 if (!this.resultType && this.hasVideoFilter()) { 96 if (!this.resultType && this.hasVideoFilter()) {
91 this.resultType = 'videos' 97 this.resultType = 'videos'
92 } 98 }
@@ -138,7 +144,8 @@ export class AdvancedSearch {
138 host: this.host, 144 host: this.host,
139 sort: this.sort, 145 sort: this.sort,
140 searchTarget: this.searchTarget, 146 searchTarget: this.searchTarget,
141 resultType: this.resultType 147 resultType: this.resultType,
148 excludeAlreadyWatched: this.excludeAlreadyWatched
142 } 149 }
143 } 150 }
144 151
@@ -162,7 +169,8 @@ export class AdvancedSearch {
162 host: this.host, 169 host: this.host,
163 isLive, 170 isLive,
164 sort: this.sort, 171 sort: this.sort,
165 searchTarget: this.searchTarget 172 searchTarget: this.searchTarget,
173 excludeAlreadyWatched: this.excludeAlreadyWatched
166 } 174 }
167 } 175 }
168 176
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 () {
diff --git a/shared/models/search/videos-common-query.model.ts b/shared/models/search/videos-common-query.model.ts
index 2cbf7b014..da479c928 100644
--- a/shared/models/search/videos-common-query.model.ts
+++ b/shared/models/search/videos-common-query.model.ts
@@ -35,6 +35,8 @@ export interface VideosCommonQuery {
35 skipCount?: boolean 35 skipCount?: boolean
36 36
37 search?: string 37 search?: string
38
39 excludeAlreadyWatched?: boolean
38} 40}
39 41
40export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery { 42export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery {
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 046eec544..a36ae0c7e 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -717,6 +717,7 @@ paths:
717 - $ref: '#/components/parameters/start' 717 - $ref: '#/components/parameters/start'
718 - $ref: '#/components/parameters/count' 718 - $ref: '#/components/parameters/count'
719 - $ref: '#/components/parameters/videosSort' 719 - $ref: '#/components/parameters/videosSort'
720 - $ref: '#/components/parameters/excludeAlreadyWatched'
720 responses: 721 responses:
721 '200': 722 '200':
722 description: successful operation 723 description: successful operation
@@ -1835,6 +1836,7 @@ paths:
1835 - $ref: '#/components/parameters/start' 1836 - $ref: '#/components/parameters/start'
1836 - $ref: '#/components/parameters/count' 1837 - $ref: '#/components/parameters/count'
1837 - $ref: '#/components/parameters/videosSort' 1838 - $ref: '#/components/parameters/videosSort'
1839 - $ref: '#/components/parameters/excludeAlreadyWatched'
1838 responses: 1840 responses:
1839 '200': 1841 '200':
1840 description: successful operation 1842 description: successful operation
@@ -2378,6 +2380,7 @@ paths:
2378 - $ref: '#/components/parameters/start' 2380 - $ref: '#/components/parameters/start'
2379 - $ref: '#/components/parameters/count' 2381 - $ref: '#/components/parameters/count'
2380 - $ref: '#/components/parameters/videosSort' 2382 - $ref: '#/components/parameters/videosSort'
2383 - $ref: '#/components/parameters/excludeAlreadyWatched'
2381 responses: 2384 responses:
2382 '200': 2385 '200':
2383 description: successful operation 2386 description: successful operation
@@ -3799,6 +3802,7 @@ paths:
3799 - $ref: '#/components/parameters/start' 3802 - $ref: '#/components/parameters/start'
3800 - $ref: '#/components/parameters/count' 3803 - $ref: '#/components/parameters/count'
3801 - $ref: '#/components/parameters/videosSort' 3804 - $ref: '#/components/parameters/videosSort'
3805 - $ref: '#/components/parameters/excludeAlreadyWatched'
3802 responses: 3806 responses:
3803 '200': 3807 '200':
3804 description: successful operation 3808 description: successful operation
@@ -4742,6 +4746,7 @@ paths:
4742 - $ref: '#/components/parameters/count' 4746 - $ref: '#/components/parameters/count'
4743 - $ref: '#/components/parameters/searchTarget' 4747 - $ref: '#/components/parameters/searchTarget'
4744 - $ref: '#/components/parameters/videosSearchSort' 4748 - $ref: '#/components/parameters/videosSearchSort'
4749 - $ref: '#/components/parameters/excludeAlreadyWatched'
4745 - name: startDate 4750 - name: startDate
4746 in: query 4751 in: query
4747 description: Get videos that are published after this date 4752 description: Get videos that are published after this date
@@ -5872,6 +5877,12 @@ components:
5872 schema: 5877 schema:
5873 $ref: '#/components/schemas/VideoPrivacySet' 5878 $ref: '#/components/schemas/VideoPrivacySet'
5874 description: '**PeerTube >= 4.0** Display only videos in this specific privacy/privacies' 5879 description: '**PeerTube >= 4.0** Display only videos in this specific privacy/privacies'
5880 excludeAlreadyWatched:
5881 name: excludeAlreadyWatched
5882 in: query
5883 description: Whether or not to exclude videos that are in the user's video history
5884 schema:
5885 type: boolean
5875 uuids: 5886 uuids:
5876 name: uuids 5887 name: uuids
5877 in: query 5888 in: query