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',
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue