Add channel filters for my videos/followers

This commit is contained in:
Chocobozzz 2021-10-20 09:05:43 +02:00
parent 7e76cc3800
commit 978c87e7f5
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
15 changed files with 207 additions and 54 deletions

View File

@ -28,12 +28,17 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
inputFilters: AdvancedInputFilter[] = [ inputFilters: AdvancedInputFilter[] = [
{ {
queryParams: { search: 'type:auto' }, title: $localize`Advanced filters`,
label: $localize`Automatic blocks` children: [
}, {
{ queryParams: { search: 'type:auto' },
queryParams: { search: 'type:manual' }, label: $localize`Automatic blocks`
label: $localize`Manual blocks` },
{
queryParams: { search: 'type:manual' },
label: $localize`Manual blocks`
}
]
} }
] ]

View File

@ -44,12 +44,17 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
inputFilters: AdvancedInputFilter[] = [ inputFilters: AdvancedInputFilter[] = [
{ {
queryParams: { search: 'local:true' }, title: $localize`Advanced filters`,
label: $localize`Local comments` children: [
}, {
{ queryParams: { search: 'local:true' },
queryParams: { search: 'local:false' }, label: $localize`Local comments`
label: $localize`Remote comments` },
{
queryParams: { search: 'local:false' },
label: $localize`Remote comments`
}
]
} }
] ]

View File

@ -36,8 +36,13 @@ export class UserListComponent extends RestTable implements OnInit {
inputFilters: AdvancedInputFilter[] = [ inputFilters: AdvancedInputFilter[] = [
{ {
queryParams: { search: 'banned:true' }, title: $localize`Advanced filters`,
label: $localize`Banned users` children: [
{
queryParams: { search: 'banned:true' },
label: $localize`Banned users`
}
]
} }
] ]

View File

@ -37,12 +37,19 @@ export class MyFollowersComponent implements OnInit {
} }
this.auth.userInformationLoaded.subscribe(() => { this.auth.userInformationLoaded.subscribe(() => {
this.inputFilters = this.auth.getUser().videoChannels.map(c => { const channelFilters = this.auth.getUser().videoChannels.map(c => {
return { return {
queryParams: { search: 'channel:' + c.name }, queryParams: { search: 'channel:' + c.name },
label: $localize`Followers of ${c.name}` label: c.name
} }
}) })
this.inputFilters = [
{
title: $localize`Channel filters`,
children: channelFilters
}
]
}) })
} }

View File

@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@shared/models' import { VideoChannel, VideoSortField } from '@shared/models'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
@Component({ @Component({
@ -47,16 +47,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
user: User user: User
inputFilters: AdvancedInputFilter[] = [ inputFilters: AdvancedInputFilter[]
{
queryParams: { search: 'isLive:true' },
label: $localize`Only live videos`
}
]
disabled = false disabled = false
private search: string private search: string
private userChannels: VideoChannel[] = []
constructor ( constructor (
protected router: Router, protected router: Router,
@ -79,6 +75,35 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
if (this.route.snapshot.queryParams['search']) { if (this.route.snapshot.queryParams['search']) {
this.search = this.route.snapshot.queryParams['search'] this.search = this.route.snapshot.queryParams['search']
} }
this.authService.userInformationLoaded.subscribe(() => {
this.user = this.authService.getUser()
this.userChannels = this.user.videoChannels
const channelFilters = this.userChannels.map(c => {
return {
queryParams: { search: 'channel:' + c.name },
label: c.name
}
})
this.inputFilters = [
{
title: $localize`Advanced filters`,
children: [
{
queryParams: { search: 'isLive:true' },
label: $localize`Only live videos`
}
]
},
{
title: $localize`Channel filters`,
children: channelFilters
}
]
})
} }
onSearch (search: string) { onSearch (search: string) {
@ -105,7 +130,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
getVideosObservable (page: number) { getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page }) const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getMyVideos(newPagination, this.sort, this.search) return this.videoService.getMyVideos({
videoPagination: newPagination,
sort: this.sort,
userChannels: this.userChannels,
search: this.search
})
.pipe( .pipe(
tap(res => this.pagination.totalItems = res.total) tap(res => this.pagination.totalItems = res.total)
) )

View File

@ -39,24 +39,29 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
inputFilters: AdvancedInputFilter[] = [ inputFilters: AdvancedInputFilter[] = [
{ {
queryParams: { search: 'state:pending' }, title: $localize`Advanced filters`,
label: $localize`Unsolved reports` children: [
}, {
{ queryParams: { search: 'state:pending' },
queryParams: { search: 'state:accepted' }, label: $localize`Unsolved reports`
label: $localize`Accepted reports` },
}, {
{ queryParams: { search: 'state:accepted' },
queryParams: { search: 'state:rejected' }, label: $localize`Accepted reports`
label: $localize`Refused reports` },
}, {
{ queryParams: { search: 'state:rejected' },
queryParams: { search: 'videoIs:blacklisted' }, label: $localize`Refused reports`
label: $localize`Reports with blocked videos` },
}, {
{ queryParams: { search: 'videoIs:blacklisted' },
queryParams: { search: 'videoIs:deleted' }, label: $localize`Reports with blocked videos`
label: $localize`Reports with deleted videos` },
{
queryParams: { search: 'videoIs:deleted' },
label: $localize`Reports with deleted videos`
}
]
} }
] ]

View File

@ -5,11 +5,13 @@
</div> </div>
<div role="menu" ngbDropdownMenu> <div role="menu" ngbDropdownMenu>
<h6 class="dropdown-header" i18n>Advanced filters</h6> <ng-container *ngFor="let group of filters">
<h6 class="dropdown-header">{{ group.title }}</h6>
<a *ngFor="let filter of filters" [routerLink]="[ '.' ]" [queryParams]="filter.queryParams" class="dropdown-item"> <a *ngFor="let filter of group.children" [routerLink]="[ '.' ]" [queryParams]="filter.queryParams" class="dropdown-item">
{{ filter.label }} {{ filter.label }}
</a> </a>
</ng-container>
</div> </div>
</div> </div>

View File

@ -5,8 +5,12 @@ import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output } from '@
import { ActivatedRoute, Params, Router } from '@angular/router' import { ActivatedRoute, Params, Router } from '@angular/router'
export type AdvancedInputFilter = { export type AdvancedInputFilter = {
label: string title: string
queryParams: Params
children: {
label: string
queryParams: Params
}[]
} }
const logger = debug('peertube:AdvancedInputFilterComponent') const logger = debug('peertube:AdvancedInputFilterComponent')

View File

@ -13,6 +13,7 @@ import {
UserVideoRateType, UserVideoRateType,
UserVideoRateUpdate, UserVideoRateUpdate,
Video as VideoServerModel, Video as VideoServerModel,
VideoChannel as VideoChannelServerModel,
VideoConstant, VideoConstant,
VideoDetails as VideoDetailsServerModel, VideoDetails as VideoDetailsServerModel,
VideoFileMetadata, VideoFileMetadata,
@ -122,7 +123,14 @@ export class VideoService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> { getMyVideos (options: {
videoPagination: ComponentPaginationLight
sort: VideoSortField
userChannels?: VideoChannelServerModel[]
search?: string
}): Observable<ResultList<Video>> {
const { videoPagination, sort, userChannels = [], search } = options
const pagination = this.restService.componentToRestPagination(videoPagination) const pagination = this.restService.componentToRestPagination(videoPagination)
let params = new HttpParams() let params = new HttpParams()
@ -133,6 +141,16 @@ export class VideoService {
isLive: { isLive: {
prefix: 'isLive:', prefix: 'isLive:',
isBoolean: true isBoolean: true
},
channelId: {
prefix: 'channel:',
handler: (name: string) => {
const channel = userChannels.find(c => c.name === name)
if (channel) return channel.id
return undefined
}
} }
}) })

View File

@ -25,7 +25,7 @@ import {
usersUpdateMeValidator, usersUpdateMeValidator,
usersVideoRatingValidator usersVideoRatingValidator
} from '../../../middlewares' } from '../../../middlewares'
import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators' import { deleteMeValidator, usersVideosValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators'
import { updateAvatarValidator } from '../../../middlewares/validators/actor-image' import { updateAvatarValidator } from '../../../middlewares/validators/actor-image'
import { AccountModel } from '../../../models/account/account' import { AccountModel } from '../../../models/account/account'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
@ -69,6 +69,7 @@ meRouter.get('/me/videos',
videosSortValidator, videosSortValidator,
setDefaultVideosSort, setDefaultVideosSort,
setDefaultPagination, setDefaultPagination,
asyncMiddleware(usersVideosValidator),
asyncMiddleware(getUserVideos) asyncMiddleware(getUserVideos)
) )
@ -113,6 +114,7 @@ async function getUserVideos (req: express.Request, res: express.Response) {
count: req.query.count, count: req.query.count,
sort: req.query.sort, sort: req.query.sort,
search: req.query.search, search: req.query.search,
channelId: res.locals.videoChannel?.id,
isLive: req.query.isLive isLive: req.query.isLive
}, 'filter:api.user.me.videos.list.params') }, 'filter:api.user.me.videos.list.params')

View File

@ -4,7 +4,7 @@ import { omit } from 'lodash'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { MUserDefault } from '@server/types/models' import { MUserDefault } from '@server/types/models'
import { HttpStatusCode, UserRegister, UserRole } from '@shared/models' import { HttpStatusCode, UserRegister, UserRole } from '@shared/models'
import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins' import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import { import {
isUserAdminFlagsValid, isUserAdminFlagsValid,
@ -31,7 +31,7 @@ import { Redis } from '../../lib/redis'
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
import { ActorModel } from '../../models/actor/actor' import { ActorModel } from '../../models/actor/actor'
import { UserModel } from '../../models/user/user' import { UserModel } from '../../models/user/user'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from './shared' import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared'
const usersListValidator = [ const usersListValidator = [
query('blocked') query('blocked')
@ -318,6 +318,28 @@ const usersVideoRatingValidator = [
} }
] ]
const usersVideosValidator = [
query('isLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid live boolean'),
query('channelId')
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid).withMessage('Should have a valid channel id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersVideosValidator parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (req.query.channelId && !await doesVideoChannelIdExist(req.query.channelId, res)) return
return next()
}
]
const ensureUserRegistrationAllowed = [ const ensureUserRegistrationAllowed = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const allowedParams = { const allowedParams = {
@ -513,6 +535,7 @@ export {
ensureUserRegistrationAllowed, ensureUserRegistrationAllowed,
ensureUserRegistrationAllowedForIP, ensureUserRegistrationAllowedForIP,
usersGetValidator, usersGetValidator,
usersVideosValidator,
usersAskResetPasswordValidator, usersAskResetPasswordValidator,
usersResetPasswordValidator, usersResetPasswordValidator,
usersAskSendVerifyEmailValidator, usersAskSendVerifyEmailValidator,

View File

@ -978,10 +978,12 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
start: number start: number
count: number count: number
sort: string sort: string
channelId?: number
isLive?: boolean isLive?: boolean
search?: string search?: string
}) { }) {
const { accountId, start, count, sort, search, isLive } = options const { accountId, channelId, start, count, sort, search, isLive } = options
function buildBaseQuery (): FindOptions { function buildBaseQuery (): FindOptions {
const where: WhereOptions = {} const where: WhereOptions = {}
@ -996,6 +998,10 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
where.isLive = isLive where.isLive = isLive
} }
const channelWhere = channelId
? { id: channelId }
: {}
const baseQuery = { const baseQuery = {
offset: start, offset: start,
limit: count, limit: count,
@ -1005,6 +1011,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
{ {
model: VideoChannelModel, model: VideoChannelModel,
required: true, required: true,
where: channelWhere,
include: [ include: [
{ {
model: AccountModel, model: AccountModel,

View File

@ -119,6 +119,20 @@ describe('Test videos API validator', function () {
await checkBadSortPagination(server.url, path, server.accessToken) await checkBadSortPagination(server.url, path, server.accessToken)
}) })
it('Should fail with an invalid channel', async function () {
await makeGetRequest({ url: server.url, token: server.accessToken, path, query: { channelId: 'toto' } })
})
it('Should fail with an unknown channel', async function () {
await makeGetRequest({
url: server.url,
token: server.accessToken,
path,
query: { channelId: 89898 },
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should success with the correct parameters', async function () { it('Should success with the correct parameters', async function () {
await makeGetRequest({ url: server.url, token: server.accessToken, path, expectedStatus: HttpStatusCode.OK_200 }) await makeGetRequest({ url: server.url, token: server.accessToken, path, expectedStatus: HttpStatusCode.OK_200 })
}) })

View File

@ -318,6 +318,8 @@ describe('Test users', function () {
fixture: 'video_short.webm' fixture: 'video_short.webm'
} }
await server.videos.upload({ token: userToken, attributes }) await server.videos.upload({ token: userToken, attributes })
await server.channels.create({ token: userToken, attributes: { name: 'other_channel' } })
}) })
it('Should have video quota updated', async function () { it('Should have video quota updated', async function () {
@ -340,6 +342,29 @@ describe('Test users', function () {
expect(video.previewPath).to.not.be.null expect(video.previewPath).to.not.be.null
}) })
it('Should be able to filter by channel in my videos', async function () {
const myInfo = await server.users.getMyInfo({ token: userToken })
const mainChannel = myInfo.videoChannels.find(c => c.name !== 'other_channel')
const otherChannel = myInfo.videoChannels.find(c => c.name === 'other_channel')
{
const { total, data } = await server.videos.listMyVideos({ token: userToken, channelId: mainChannel.id })
expect(total).to.equal(1)
expect(data).to.have.lengthOf(1)
const video: Video = data[0]
expect(video.name).to.equal('super user video')
expect(video.thumbnailPath).to.not.be.null
expect(video.previewPath).to.not.be.null
}
{
const { total, data } = await server.videos.listMyVideos({ token: userToken, channelId: otherChannel.id })
expect(total).to.equal(0)
expect(data).to.have.lengthOf(0)
}
})
it('Should be able to search in my videos', async function () { it('Should be able to search in my videos', async function () {
{ {
const { total, data } = await server.videos.listMyVideos({ token: userToken, sort: '-createdAt', search: 'user video' }) const { total, data } = await server.videos.listMyVideos({ token: userToken, sort: '-createdAt', search: 'user video' })

View File

@ -207,6 +207,7 @@ export class VideosCommand extends AbstractCommand {
sort?: string sort?: string
search?: string search?: string
isLive?: boolean isLive?: boolean
channelId?: number
} = {}) { } = {}) {
const path = '/api/v1/users/me/videos' const path = '/api/v1/users/me/videos'
@ -214,7 +215,7 @@ export class VideosCommand extends AbstractCommand {
...options, ...options,
path, path,
query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive' ]), query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
implicitToken: true, implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200 defaultExpectedStatus: HttpStatusCode.OK_200
}) })