Feature/filter already watched videos (#5739)
* filter already watched videos * Updated code based on review comments
This commit is contained in:
parent
0cda019c1d
commit
2a4c0d8bbe
10 changed files with 119 additions and 9 deletions
|
@ -63,6 +63,9 @@ export class RecentVideosRecommendationService implements RecommendationService
|
|||
searchTarget: 'local',
|
||||
nsfw: user.nsfwPolicy
|
||||
? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
|
||||
: undefined,
|
||||
excludeAlreadyWatched: user.id
|
||||
? true
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
|
|
@ -40,6 +40,8 @@ export class AdvancedSearch {
|
|||
searchTarget: SearchTargetType
|
||||
resultType: AdvancedSearchResultType
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
|
||||
constructor (options?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
|
@ -62,6 +64,8 @@ export class AdvancedSearch {
|
|||
sort?: string
|
||||
searchTarget?: SearchTargetType
|
||||
resultType?: AdvancedSearchResultType
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
}) {
|
||||
if (!options) return
|
||||
|
||||
|
@ -87,6 +91,8 @@ export class AdvancedSearch {
|
|||
|
||||
this.resultType = options.resultType || undefined
|
||||
|
||||
this.excludeAlreadyWatched = options.excludeAlreadyWatched || undefined
|
||||
|
||||
if (!this.resultType && this.hasVideoFilter()) {
|
||||
this.resultType = 'videos'
|
||||
}
|
||||
|
@ -138,7 +144,8 @@ export class AdvancedSearch {
|
|||
host: this.host,
|
||||
sort: this.sort,
|
||||
searchTarget: this.searchTarget,
|
||||
resultType: this.resultType
|
||||
resultType: this.resultType,
|
||||
excludeAlreadyWatched: this.excludeAlreadyWatched
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -162,7 +169,8 @@ export class AdvancedSearch {
|
|||
host: this.host,
|
||||
isLive,
|
||||
sort: this.sort,
|
||||
searchTarget: this.searchTarget
|
||||
searchTarget: this.searchTarget,
|
||||
excludeAlreadyWatched: this.excludeAlreadyWatched
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,8 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
|
|||
'skipCount',
|
||||
'hasHLSFiles',
|
||||
'hasWebtorrentFiles',
|
||||
'search'
|
||||
'search',
|
||||
'excludeAlreadyWatched'
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -41,7 +42,8 @@ function pickSearchVideoQuery (query: VideosSearchQueryAfterSanitize) {
|
|||
'originallyPublishedEndDate',
|
||||
'durationMin',
|
||||
'durationMax',
|
||||
'uuids'
|
||||
'uuids',
|
||||
'excludeAlreadyWatched'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -489,6 +489,10 @@ const commonVideosFiltersValidator = [
|
|||
query('search')
|
||||
.optional()
|
||||
.custom(exists),
|
||||
query('excludeAlreadyWatched')
|
||||
.optional()
|
||||
.customSanitizer(toBooleanOrNull)
|
||||
.isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
|
||||
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
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()
|
||||
}
|
||||
]
|
||||
|
|
|
@ -78,6 +78,8 @@ export type BuildVideosListQueryOptions = {
|
|||
|
||||
transaction?: Transaction
|
||||
logging?: boolean
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
}
|
||||
|
||||
export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
||||
|
@ -260,6 +262,14 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
|||
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)
|
||||
|
||||
if (options.isCount === true) {
|
||||
|
@ -598,6 +608,18 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
|||
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) {
|
||||
const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
|
||||
|
||||
|
|
|
@ -1086,6 +1086,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
|||
countVideos?: boolean
|
||||
|
||||
search?: string
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
}) {
|
||||
VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
|
||||
VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
|
||||
|
@ -1124,7 +1126,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
|||
'historyOfUser',
|
||||
'hasHLSFiles',
|
||||
'hasWebtorrentFiles',
|
||||
'search'
|
||||
'search',
|
||||
'excludeAlreadyWatched'
|
||||
]),
|
||||
|
||||
serverAccountIdForBlock: serverActor.Account.id,
|
||||
|
@ -1170,6 +1173,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
|||
durationMin?: number // seconds
|
||||
durationMax?: number // seconds
|
||||
uuids?: string[]
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
}) {
|
||||
VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
|
||||
VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
|
||||
|
@ -1203,7 +1208,8 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
|||
'hasWebtorrentFiles',
|
||||
'uuids',
|
||||
'search',
|
||||
'displayOnlyForFollower'
|
||||
'displayOnlyForFollower',
|
||||
'excludeAlreadyWatched'
|
||||
]),
|
||||
serverAccountIdForBlock: serverActor.Account.id
|
||||
}
|
||||
|
|
|
@ -122,6 +122,8 @@ describe('Test video filters validators', function () {
|
|||
include?: VideoInclude
|
||||
privacyOneOf?: VideoPrivacy[]
|
||||
expectedStatus: HttpStatusCode
|
||||
excludeAlreadyWatched?: boolean
|
||||
unauthenticatedUser?: boolean
|
||||
}) {
|
||||
const paths = [
|
||||
'/api/v1/video-channels/root_channel/videos',
|
||||
|
@ -131,14 +133,19 @@ describe('Test video filters validators', function () {
|
|||
]
|
||||
|
||||
for (const path of paths) {
|
||||
const token = options.unauthenticatedUser
|
||||
? undefined
|
||||
: options.token || server.accessToken
|
||||
|
||||
await makeGetRequest({
|
||||
url: server.url,
|
||||
path,
|
||||
token: options.token || server.accessToken,
|
||||
token,
|
||||
query: {
|
||||
isLocal: options.isLocal,
|
||||
privacyOneOf: options.privacyOneOf,
|
||||
include: options.include
|
||||
include: options.include,
|
||||
excludeAlreadyWatched: options.excludeAlreadyWatched
|
||||
},
|
||||
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 () {
|
||||
|
|
|
@ -162,13 +162,23 @@ describe('Test videos filter', function () {
|
|||
tagsAllOf?: string[]
|
||||
token?: string
|
||||
expectedStatus?: HttpStatusCode
|
||||
excludeAlreadyWatched?: boolean
|
||||
}) {
|
||||
const res = await makeGetRequest({
|
||||
url: options.server.url,
|
||||
path: options.path,
|
||||
token: options.token ?? options.server.accessToken,
|
||||
query: {
|
||||
...pick(options, [ 'isLocal', 'include', 'category', 'tagsAllOf', 'hasWebtorrentFiles', 'hasHLSFiles', 'privacyOneOf' ]),
|
||||
...pick(options, [
|
||||
'isLocal',
|
||||
'include',
|
||||
'category',
|
||||
'tagsAllOf',
|
||||
'hasWebtorrentFiles',
|
||||
'hasHLSFiles',
|
||||
'privacyOneOf',
|
||||
'excludeAlreadyWatched'
|
||||
]),
|
||||
|
||||
sort: 'createdAt'
|
||||
},
|
||||
|
@ -187,6 +197,7 @@ describe('Test videos filter', function () {
|
|||
token?: string
|
||||
expectedStatus?: HttpStatusCode
|
||||
skipSubscription?: boolean
|
||||
excludeAlreadyWatched?: boolean
|
||||
}
|
||||
) {
|
||||
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 () {
|
||||
|
|
|
@ -35,6 +35,8 @@ export interface VideosCommonQuery {
|
|||
skipCount?: boolean
|
||||
|
||||
search?: string
|
||||
|
||||
excludeAlreadyWatched?: boolean
|
||||
}
|
||||
|
||||
export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery {
|
||||
|
|
|
@ -717,6 +717,7 @@ paths:
|
|||
- $ref: '#/components/parameters/start'
|
||||
- $ref: '#/components/parameters/count'
|
||||
- $ref: '#/components/parameters/videosSort'
|
||||
- $ref: '#/components/parameters/excludeAlreadyWatched'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
@ -1835,6 +1836,7 @@ paths:
|
|||
- $ref: '#/components/parameters/start'
|
||||
- $ref: '#/components/parameters/count'
|
||||
- $ref: '#/components/parameters/videosSort'
|
||||
- $ref: '#/components/parameters/excludeAlreadyWatched'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
@ -2378,6 +2380,7 @@ paths:
|
|||
- $ref: '#/components/parameters/start'
|
||||
- $ref: '#/components/parameters/count'
|
||||
- $ref: '#/components/parameters/videosSort'
|
||||
- $ref: '#/components/parameters/excludeAlreadyWatched'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
@ -3799,6 +3802,7 @@ paths:
|
|||
- $ref: '#/components/parameters/start'
|
||||
- $ref: '#/components/parameters/count'
|
||||
- $ref: '#/components/parameters/videosSort'
|
||||
- $ref: '#/components/parameters/excludeAlreadyWatched'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
@ -4742,6 +4746,7 @@ paths:
|
|||
- $ref: '#/components/parameters/count'
|
||||
- $ref: '#/components/parameters/searchTarget'
|
||||
- $ref: '#/components/parameters/videosSearchSort'
|
||||
- $ref: '#/components/parameters/excludeAlreadyWatched'
|
||||
- name: startDate
|
||||
in: query
|
||||
description: Get videos that are published after this date
|
||||
|
@ -5872,6 +5877,12 @@ components:
|
|||
schema:
|
||||
$ref: '#/components/schemas/VideoPrivacySet'
|
||||
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:
|
||||
name: uuids
|
||||
in: query
|
||||
|
|
Loading…
Reference in a new issue