1
0
Fork 0

Feature/filter already watched videos (#5739)

* filter already watched videos

* Updated code based on review comments
This commit is contained in:
Wicklow 2023-04-12 07:32:20 +00:00 committed by GitHub
parent 0cda019c1d
commit 2a4c0d8bbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 119 additions and 9 deletions

View file

@ -63,6 +63,9 @@ export class RecentVideosRecommendationService implements RecommendationService
searchTarget: 'local', searchTarget: 'local',
nsfw: user.nsfwPolicy nsfw: user.nsfwPolicy
? this.videos.nsfwPolicyToParam(user.nsfwPolicy) ? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
: undefined,
excludeAlreadyWatched: user.id
? true
: undefined : undefined
}) })
} }

View file

@ -40,6 +40,8 @@ export class AdvancedSearch {
searchTarget: SearchTargetType searchTarget: SearchTargetType
resultType: AdvancedSearchResultType resultType: AdvancedSearchResultType
excludeAlreadyWatched?: boolean
constructor (options?: { constructor (options?: {
startDate?: string startDate?: string
endDate?: string endDate?: string
@ -62,6 +64,8 @@ export class AdvancedSearch {
sort?: string sort?: string
searchTarget?: SearchTargetType searchTarget?: SearchTargetType
resultType?: AdvancedSearchResultType resultType?: AdvancedSearchResultType
excludeAlreadyWatched?: boolean
}) { }) {
if (!options) return if (!options) return
@ -87,6 +91,8 @@ export class AdvancedSearch {
this.resultType = options.resultType || undefined this.resultType = options.resultType || undefined
this.excludeAlreadyWatched = options.excludeAlreadyWatched || undefined
if (!this.resultType && this.hasVideoFilter()) { if (!this.resultType && this.hasVideoFilter()) {
this.resultType = 'videos' this.resultType = 'videos'
} }
@ -138,7 +144,8 @@ export class AdvancedSearch {
host: this.host, host: this.host,
sort: this.sort, sort: this.sort,
searchTarget: this.searchTarget, searchTarget: this.searchTarget,
resultType: this.resultType resultType: this.resultType,
excludeAlreadyWatched: this.excludeAlreadyWatched
} }
} }
@ -162,7 +169,8 @@ export class AdvancedSearch {
host: this.host, host: this.host,
isLive, isLive,
sort: this.sort, sort: this.sort,
searchTarget: this.searchTarget searchTarget: this.searchTarget,
excludeAlreadyWatched: this.excludeAlreadyWatched
} }
} }

View file

@ -24,7 +24,8 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
'skipCount', 'skipCount',
'hasHLSFiles', 'hasHLSFiles',
'hasWebtorrentFiles', 'hasWebtorrentFiles',
'search' 'search',
'excludeAlreadyWatched'
]) ])
} }
@ -41,7 +42,8 @@ function pickSearchVideoQuery (query: VideosSearchQueryAfterSanitize) {
'originallyPublishedEndDate', 'originallyPublishedEndDate',
'durationMin', 'durationMin',
'durationMax', 'durationMax',
'uuids' 'uuids',
'excludeAlreadyWatched'
]) ])
} }
} }

View file

@ -489,6 +489,10 @@ const commonVideosFiltersValidator = [
query('search') query('search')
.optional() .optional()
.custom(exists), .custom(exists),
query('excludeAlreadyWatched')
.optional()
.customSanitizer(toBooleanOrNull)
.isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
@ -520,6 +524,13 @@ const commonVideosFiltersValidator = [
} }
} }
if (!user && exists(req.query.excludeAlreadyWatched)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot use excludeAlreadyWatched parameter when auth token is not provided'
})
return false
}
return next() return next()
} }
] ]

View file

@ -78,6 +78,8 @@ export type BuildVideosListQueryOptions = {
transaction?: Transaction transaction?: Transaction
logging?: boolean logging?: boolean
excludeAlreadyWatched?: boolean
} }
export class VideosIdListQueryBuilder extends AbstractRunQuery { export class VideosIdListQueryBuilder extends AbstractRunQuery {
@ -260,6 +262,14 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
this.whereDurationMax(options.durationMax) this.whereDurationMax(options.durationMax)
} }
if (options.excludeAlreadyWatched) {
if (exists(options.user.id)) {
this.whereExcludeAlreadyWatched(options.user.id)
} else {
throw new Error('Cannot use excludeAlreadyWatched parameter when auth token is not provided')
}
}
this.whereSearch(options.search) this.whereSearch(options.search)
if (options.isCount === true) { if (options.isCount === true) {
@ -598,6 +608,18 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
this.replacements.durationMax = durationMax this.replacements.durationMax = durationMax
} }
private whereExcludeAlreadyWatched (userId: number) {
this.and.push(
'NOT EXISTS (' +
' SELECT 1' +
' FROM "userVideoHistory"' +
' WHERE "video"."id" = "userVideoHistory"."videoId"' +
' AND "userVideoHistory"."userId" = :excludeAlreadyWatchedUserId' +
')'
)
this.replacements.excludeAlreadyWatchedUserId = userId
}
private groupForTrending (trendingDays: number) { private groupForTrending (trendingDays: number) {
const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)

View file

@ -1086,6 +1086,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
countVideos?: boolean countVideos?: boolean
search?: string search?: string
excludeAlreadyWatched?: boolean
}) { }) {
VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@ -1124,7 +1126,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
'historyOfUser', 'historyOfUser',
'hasHLSFiles', 'hasHLSFiles',
'hasWebtorrentFiles', 'hasWebtorrentFiles',
'search' 'search',
'excludeAlreadyWatched'
]), ]),
serverAccountIdForBlock: serverActor.Account.id, serverAccountIdForBlock: serverActor.Account.id,
@ -1170,6 +1173,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
durationMin?: number // seconds durationMin?: number // seconds
durationMax?: number // seconds durationMax?: number // seconds
uuids?: string[] uuids?: string[]
excludeAlreadyWatched?: boolean
}) { }) {
VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@ -1203,7 +1208,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
'hasWebtorrentFiles', 'hasWebtorrentFiles',
'uuids', 'uuids',
'search', 'search',
'displayOnlyForFollower' 'displayOnlyForFollower',
'excludeAlreadyWatched'
]), ]),
serverAccountIdForBlock: serverActor.Account.id serverAccountIdForBlock: serverActor.Account.id
} }

View file

@ -122,6 +122,8 @@ describe('Test video filters validators', function () {
include?: VideoInclude include?: VideoInclude
privacyOneOf?: VideoPrivacy[] privacyOneOf?: VideoPrivacy[]
expectedStatus: HttpStatusCode expectedStatus: HttpStatusCode
excludeAlreadyWatched?: boolean
unauthenticatedUser?: boolean
}) { }) {
const paths = [ const paths = [
'/api/v1/video-channels/root_channel/videos', '/api/v1/video-channels/root_channel/videos',
@ -131,14 +133,19 @@ describe('Test video filters validators', function () {
] ]
for (const path of paths) { for (const path of paths) {
const token = options.unauthenticatedUser
? undefined
: options.token || server.accessToken
await makeGetRequest({ await makeGetRequest({
url: server.url, url: server.url,
path, path,
token: options.token || server.accessToken, token,
query: { query: {
isLocal: options.isLocal, isLocal: options.isLocal,
privacyOneOf: options.privacyOneOf, privacyOneOf: options.privacyOneOf,
include: options.include include: options.include,
excludeAlreadyWatched: options.excludeAlreadyWatched
}, },
expectedStatus: options.expectedStatus expectedStatus: options.expectedStatus
}) })
@ -213,6 +220,14 @@ describe('Test video filters validators', function () {
} }
}) })
}) })
it('Should fail when trying to exclude already watched videos for an unlogged user', async function () {
await testEndpoints({ excludeAlreadyWatched: true, unauthenticatedUser: true, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed when trying to exclude already watched videos for a logged user', async function () {
await testEndpoints({ token: userAccessToken, excludeAlreadyWatched: true, expectedStatus: HttpStatusCode.OK_200 })
})
}) })
after(async function () { after(async function () {

View file

@ -162,13 +162,23 @@ describe('Test videos filter', function () {
tagsAllOf?: string[] tagsAllOf?: string[]
token?: string token?: string
expectedStatus?: HttpStatusCode expectedStatus?: HttpStatusCode
excludeAlreadyWatched?: boolean
}) { }) {
const res = await makeGetRequest({ const res = await makeGetRequest({
url: options.server.url, url: options.server.url,
path: options.path, path: options.path,
token: options.token ?? options.server.accessToken, token: options.token ?? options.server.accessToken,
query: { query: {
...pick(options, [ 'isLocal', 'include', 'category', 'tagsAllOf', 'hasWebtorrentFiles', 'hasHLSFiles', 'privacyOneOf' ]), ...pick(options, [
'isLocal',
'include',
'category',
'tagsAllOf',
'hasWebtorrentFiles',
'hasHLSFiles',
'privacyOneOf',
'excludeAlreadyWatched'
]),
sort: 'createdAt' sort: 'createdAt'
}, },
@ -187,6 +197,7 @@ describe('Test videos filter', function () {
token?: string token?: string
expectedStatus?: HttpStatusCode expectedStatus?: HttpStatusCode
skipSubscription?: boolean skipSubscription?: boolean
excludeAlreadyWatched?: boolean
} }
) { ) {
const { skipSubscription = false } = options const { skipSubscription = false } = options
@ -525,6 +536,25 @@ describe('Test videos filter', function () {
} }
} }
}) })
it('Should filter already watched videos by the user', async function () {
const { id } = await servers[0].videos.upload({ attributes: { name: 'video for history' } })
for (const path of paths) {
const videos = await listVideos({ server: servers[0], path, isLocal: true, excludeAlreadyWatched: true })
const foundVideo = videos.find(video => video.id === id)
expect(foundVideo).to.not.be.undefined
}
await servers[0].views.view({ id, token: servers[0].accessToken })
for (const path of paths) {
const videos = await listVideos({ server: servers[0], path, excludeAlreadyWatched: true })
const foundVideo = videos.find(video => video.id === id)
expect(foundVideo).to.be.undefined
}
})
}) })
after(async function () { after(async function () {

View file

@ -35,6 +35,8 @@ export interface VideosCommonQuery {
skipCount?: boolean skipCount?: boolean
search?: string search?: string
excludeAlreadyWatched?: boolean
} }
export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery { export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery {

View file

@ -717,6 +717,7 @@ paths:
- $ref: '#/components/parameters/start' - $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/videosSort' - $ref: '#/components/parameters/videosSort'
- $ref: '#/components/parameters/excludeAlreadyWatched'
responses: responses:
'200': '200':
description: successful operation description: successful operation
@ -1835,6 +1836,7 @@ paths:
- $ref: '#/components/parameters/start' - $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/videosSort' - $ref: '#/components/parameters/videosSort'
- $ref: '#/components/parameters/excludeAlreadyWatched'
responses: responses:
'200': '200':
description: successful operation description: successful operation
@ -2378,6 +2380,7 @@ paths:
- $ref: '#/components/parameters/start' - $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/videosSort' - $ref: '#/components/parameters/videosSort'
- $ref: '#/components/parameters/excludeAlreadyWatched'
responses: responses:
'200': '200':
description: successful operation description: successful operation
@ -3799,6 +3802,7 @@ paths:
- $ref: '#/components/parameters/start' - $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/videosSort' - $ref: '#/components/parameters/videosSort'
- $ref: '#/components/parameters/excludeAlreadyWatched'
responses: responses:
'200': '200':
description: successful operation description: successful operation
@ -4742,6 +4746,7 @@ paths:
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/searchTarget' - $ref: '#/components/parameters/searchTarget'
- $ref: '#/components/parameters/videosSearchSort' - $ref: '#/components/parameters/videosSearchSort'
- $ref: '#/components/parameters/excludeAlreadyWatched'
- name: startDate - name: startDate
in: query in: query
description: Get videos that are published after this date description: Get videos that are published after this date
@ -5872,6 +5877,12 @@ components:
schema: schema:
$ref: '#/components/schemas/VideoPrivacySet' $ref: '#/components/schemas/VideoPrivacySet'
description: '**PeerTube >= 4.0** Display only videos in this specific privacy/privacies' description: '**PeerTube >= 4.0** Display only videos in this specific privacy/privacies'
excludeAlreadyWatched:
name: excludeAlreadyWatched
in: query
description: Whether or not to exclude videos that are in the user's video history
schema:
type: boolean
uuids: uuids:
name: uuids name: uuids
in: query in: query