diff options
Diffstat (limited to 'server')
-rw-r--r-- | server/helpers/query.ts | 6 | ||||
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 11 | ||||
-rw-r--r-- | server/models/video/sql/video/videos-id-list-query-builder.ts | 22 | ||||
-rw-r--r-- | server/models/video/video.ts | 10 | ||||
-rw-r--r-- | server/tests/api/check-params/videos-common-filters.ts | 19 | ||||
-rw-r--r-- | server/tests/api/videos/videos-common-filters.ts | 32 |
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 | ||
83 | export class VideosIdListQueryBuilder extends AbstractRunQuery { | 85 | export 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 () { |