diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html index 9a6c124e1..a9e0931f8 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html +++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html @@ -3,4 +3,4 @@ Reports - + diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.html b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.html index c7275de1b..cf2466bdb 100644 --- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.html +++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.html @@ -13,25 +13,7 @@
-
-
-
- -
- -
- - Automatic blocks - Manual blocks -
-
- - - Clear filters -
+
diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts index d6aca10e7..dfd8dc745 100644 --- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts +++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts @@ -6,6 +6,7 @@ import { AfterViewInit, Component, OnInit } from '@angular/core' import { DomSanitizer } from '@angular/platform-browser' import { ActivatedRoute, Params, Router } from '@angular/router' import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' +import { AdvancedInputFilter } from '@app/shared/shared-forms' import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { VideoBlockService } from '@app/shared/shared-moderation' import { VideoBlacklist, VideoBlacklistType } from '@shared/models' @@ -24,6 +25,17 @@ export class VideoBlockListComponent extends RestTable implements OnInit, AfterV videoBlocklistActions: DropdownAction[][] = [] + inputFilters: AdvancedInputFilter[] = [ + { + queryParams: { 'search': 'type:auto' }, + label: $localize`Automatic blocks` + }, + { + queryParams: { 'search': 'type:manual' }, + label: $localize`Manual blocks` + } + ] + constructor ( protected route: ActivatedRoute, protected router: Router, @@ -111,25 +123,6 @@ export class VideoBlockListComponent extends RestTable implements OnInit, AfterV if (this.search) this.setTableFilter(this.search, false) } - /* Table filter functions */ - onBlockSearch (event: Event) { - this.onSearch(event) - this.setQueryParams((event.target as HTMLInputElement).value) - } - - setQueryParams (search: string) { - const queryParams: Params = {} - if (search) Object.assign(queryParams, { search }) - this.router.navigate([ '/admin/moderation/video-blocks/list' ], { queryParams }) - } - - resetTableFilter () { - this.setTableFilter('') - this.setQueryParams('') - this.resetSearch() - } - /* END Table filter functions */ - getIdentifier () { return 'VideoBlockListComponent' } diff --git a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html b/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html index b6cec9c51..5cc0ff137 100644 --- a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html +++ b/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.html @@ -26,25 +26,7 @@
-
-
-
- -
- -
- - Local comments - Remote comments -
-
- - - Clear filters -
+
diff --git a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts b/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts index 63493d00d..ebbbddb43 100644 --- a/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts +++ b/client/src/app/+admin/moderation/video-comment-list/video-comment-list.component.ts @@ -2,6 +2,7 @@ import { SortMeta } from 'primeng/api' import { AfterViewInit, Component, OnInit } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' +import { AdvancedInputFilter } from '@app/shared/shared-forms' import { DropdownAction } from '@app/shared/shared-main' import { BulkService } from '@app/shared/shared-moderation' import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment' @@ -43,6 +44,17 @@ export class VideoCommentListComponent extends RestTable implements OnInit, Afte selectedComments: VideoCommentAdmin[] = [] bulkCommentActions: DropdownAction[] = [] + inputFilters: AdvancedInputFilter[] = [ + { + queryParams: { 'search': 'local:true' }, + label: $localize`Local comments` + }, + { + queryParams: { 'search': 'local:false' }, + label: $localize`Remote comments` + } + ] + get authUser () { return this.auth.getUser() } diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html index f84d3fd0c..7170d7019 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.html +++ b/client/src/app/+admin/users/user-list/user-list.component.html @@ -22,24 +22,7 @@
-
-
-
- -
- -
- - Banned users -
-
- - - Clear filters -
+
diff --git a/client/src/app/+admin/users/user-list/user-list.component.scss b/client/src/app/+admin/users/user-list/user-list.component.scss index f18747ec3..db4979a51 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.scss +++ b/client/src/app/+admin/users/user-list/user-list.component.scss @@ -11,6 +11,7 @@ tr.banned > td { .table-email { @include disable-default-a-behaviour; + color: pvar(--mainForegroundColor); } @@ -28,14 +29,6 @@ tr.banned > td { margin-left: 0.1rem; } -.caption { - justify-content: space-between; - - input { - @include peertube-input-text(250px); - } -} - p-tableCheckbox { position: relative; top: -2.5px; @@ -55,18 +48,7 @@ my-global-icon { .progress { @include progressbar($small: true); + width: auto; max-width: 100%; } - -.input-group { - @include peertube-input-group(300px); - - input { - flex: 1; - } - - .dropdown-toggle::after { - margin-left: 0; - } -} diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts index 339e18206..435bc17d7 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.ts +++ b/client/src/app/+admin/users/user-list/user-list.component.ts @@ -1,8 +1,9 @@ import { SortMeta } from 'primeng/api' -import { Component, OnInit, ViewChild } from '@angular/core' -import { ActivatedRoute, Params, Router } from '@angular/router' +import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService, UserService } from '@app/core' -import { Account, DropdownAction } from '@app/shared/shared-main' +import { AdvancedInputFilter } from '@app/shared/shared-forms' +import { DropdownAction } from '@app/shared/shared-main' import { UserBanModalComponent } from '@app/shared/shared-moderation' import { ServerConfig, User, UserRole } from '@shared/models' @@ -18,19 +19,28 @@ type UserForList = User & { templateUrl: './user-list.component.html', styleUrls: [ './user-list.component.scss' ] }) -export class UserListComponent extends RestTable implements OnInit { +export class UserListComponent extends RestTable implements OnInit, AfterViewInit { @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent users: User[] = [] + totalRecords = 0 sort: SortMeta = { field: 'createdAt', order: 1 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + highlightBannedUsers = false selectedUsers: User[] = [] bulkUserActions: DropdownAction[][] = [] columns: { id: string, label: string }[] + inputFilters: AdvancedInputFilter[] = [ + { + queryParams: { 'search': 'banned:true' }, + label: $localize`Banned users` + } + ] + private _selectedColumns: string[] private serverConfig: ServerConfig @@ -117,6 +127,10 @@ export class UserListComponent extends RestTable implements OnInit { this.columns.push({ id: 'lastLoginDate', label: 'Last login' }) } + ngAfterViewInit () { + if (this.search) this.setTableFilter(this.search, false) + } + getIdentifier () { return 'UserListComponent' } diff --git a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html index 59ca61be6..e83b59019 100644 --- a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html +++ b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html @@ -3,4 +3,4 @@ Reports - + diff --git a/client/src/app/+my-library/my-videos/my-videos.component.html b/client/src/app/+my-library/my-videos/my-videos.component.html index e9f436378..7c1cdb511 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.html +++ b/client/src/app/+my-library/my-videos/my-videos.component.html @@ -19,12 +19,7 @@
-
- - - Clear filters -
+
- - Clear filters -
+
@@ -171,7 +150,7 @@ - + diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts index 8b5771237..f393c0d1e 100644 --- a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts @@ -14,6 +14,7 @@ import { AbuseState, AdminAbuse } from '@shared/models' import { AbuseMessageModalComponent } from './abuse-message-modal.component' import { ModerationCommentModalComponent } from './moderation-comment-modal.component' import { ProcessedAbuse } from './processed-abuse.model' +import { AdvancedInputFilter } from '../shared-forms' const logger = debug('peertube:moderation:AbuseListTableComponent') @@ -24,7 +25,6 @@ const logger = debug('peertube:moderation:AbuseListTableComponent') }) export class AbuseListTableComponent extends RestTable implements OnInit, AfterViewInit { @Input() viewType: 'admin' | 'user' - @Input() baseRoute: string @ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent @@ -36,6 +36,29 @@ export class AbuseListTableComponent extends RestTable implements OnInit, AfterV abuseActions: DropdownAction[][] = [] + inputFilters: AdvancedInputFilter[] = [ + { + queryParams: { 'search': 'state:pending' }, + label: $localize`Unsolved reports` + }, + { + queryParams: { 'search': 'state:accepted' }, + label: $localize`Accepted reports` + }, + { + queryParams: { 'search': 'state:rejected' }, + label: $localize`Refused reports` + }, + { + queryParams: { 'search': 'videoIs:blacklisted' }, + label: $localize`Reports with blocked videos` + }, + { + queryParams: { 'search': 'videoIs:deleted' }, + label: $localize`Reports with deleted videos` + } + ] + constructor ( protected route: ActivatedRoute, protected router: Router, diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts index e4efbe475..835e15110 100644 --- a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts +++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts @@ -98,7 +98,7 @@ export class ActorAvatarComponent { jkl: 'gray', mno: 'yellow', pqr: 'orange', - stv: 'red', + stvu: 'red', wxyz: 'dark-blue' } diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.html b/client/src/app/shared/shared-forms/advanced-input-filter.component.html new file mode 100644 index 000000000..03c4f127b --- /dev/null +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.html @@ -0,0 +1,22 @@ +
+
+
+ +
+ +
+ + + + {{ filter.label }} + + +
+
+ + + Clear filters +
diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.scss b/client/src/app/shared/shared-forms/advanced-input-filter.component.scss new file mode 100644 index 000000000..7c2198927 --- /dev/null +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.scss @@ -0,0 +1,10 @@ +@import '_variables'; +@import '_mixins'; + +input { + @include peertube-input-text(250px); +} + +.input-group-text { + background-color: transparent; +} diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts new file mode 100644 index 000000000..394090751 --- /dev/null +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts @@ -0,0 +1,27 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { Params } from '@angular/router' + +export type AdvancedInputFilter = { + label: string + queryParams: Params +} + +@Component({ + selector: 'my-advanced-input-filter', + templateUrl: './advanced-input-filter.component.html', + styleUrls: [ './advanced-input-filter.component.scss' ] +}) +export class AdvancedInputFilterComponent { + @Input() filters: AdvancedInputFilter[] = [] + + @Output() resetTableFilter = new EventEmitter() + @Output() search = new EventEmitter() + + onSearch (event: Event) { + this.search.emit(event) + } + + onResetTableFilter () { + this.resetTableFilter.emit() + } +} diff --git a/client/src/app/shared/shared-forms/index.ts b/client/src/app/shared/shared-forms/index.ts index 1d859b991..727416a40 100644 --- a/client/src/app/shared/shared-forms/index.ts +++ b/client/src/app/shared/shared-forms/index.ts @@ -1,12 +1,14 @@ -export * from './form-validator.service' +export * from './advanced-input-filter.component' export * from './form-reactive' -export * from './select' -export * from './input-toggle-hidden.component' +export * from './form-validator.service' +export * from './form-validator.service' export * from './input-switch.component' +export * from './input-toggle-hidden.component' export * from './markdown-textarea.component' export * from './peertube-checkbox.component' export * from './preview-upload.component' export * from './reactive-file.component' +export * from './select' +export * from './shared-form.module' export * from './textarea-autoresize.directive' export * from './timestamp-input.component' -export * from './shared-form.module' diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts index 9bdd138a1..5417f7342 100644 --- a/client/src/app/shared/shared-forms/shared-form.module.ts +++ b/client/src/app/shared/shared-forms/shared-form.module.ts @@ -5,6 +5,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgSelectModule } from '@ng-select/ng-select' import { SharedGlobalIconModule } from '../shared-icons' import { SharedMainModule } from '../shared-main/shared-main.module' +import { AdvancedInputFilterComponent } from './advanced-input-filter.component' import { DynamicFormFieldComponent } from './dynamic-form-field.component' import { FormValidatorService } from './form-validator.service' import { InputSwitchComponent } from './input-switch.component' @@ -52,7 +53,9 @@ import { TimestampInputComponent } from './timestamp-input.component' SelectCheckboxComponent, SelectCustomValueComponent, - DynamicFormFieldComponent + DynamicFormFieldComponent, + + AdvancedInputFilterComponent ], exports: [ @@ -78,7 +81,9 @@ import { TimestampInputComponent } from './timestamp-input.component' SelectCheckboxComponent, SelectCustomValueComponent, - DynamicFormFieldComponent + DynamicFormFieldComponent, + + AdvancedInputFilterComponent ], providers: [ diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 0b708b692..668e51f73 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -124,7 +124,23 @@ export class VideoService implements VideosProvider { let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) - params = this.restService.addObjectParams(params, { search }) + + if (search) { + const filters = this.restService.parseQueryStringFilter(search, { + isLive: { + prefix: 'isLive:', + isBoolean: true, + handler: v => { + if (v === 'true') return v + if (v === 'false') return v + + return undefined + } + } + }) + + params = this.restService.addObjectParams(params, filters) + } return this.authHttp .get>(UserService.BASE_USERS_URL + 'me/videos', { params }) diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts index 30badc8fa..0e3924841 100644 --- a/client/src/app/shared/shared-search/advanced-search.model.ts +++ b/client/src/app/shared/shared-search/advanced-search.model.ts @@ -1,4 +1,4 @@ -import { NSFWQuery, SearchTargetType } from '@shared/models' +import { BooleanBothQuery, SearchTargetType } from '@shared/models' export class AdvancedSearch { startDate: string // ISO 8601 @@ -7,7 +7,7 @@ export class AdvancedSearch { originallyPublishedStartDate: string // ISO 8601 originallyPublishedEndDate: string // ISO 8601 - nsfw: NSFWQuery + nsfw: BooleanBothQuery categoryOneOf: string @@ -33,7 +33,7 @@ export class AdvancedSearch { endDate?: string originallyPublishedStartDate?: string originallyPublishedEndDate?: string - nsfw?: NSFWQuery + nsfw?: BooleanBothQuery categoryOneOf?: string licenceOneOf?: string languageOneOf?: string diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss index 1abcd30e4..6a4d89dff 100644 --- a/client/src/sass/primeng-custom.scss +++ b/client/src/sass/primeng-custom.scss @@ -9,6 +9,10 @@ input[type=button] { border-radius: inherit; } +p-table .p-datatable-header .caption { + margin-bottom: 15px; +} + // Taken from old nova light theme body .p-disabled { @@ -512,10 +516,6 @@ p-table { .left-buttons { padding-left: 15px; } - - .input-group-text { - background-color: transparent; - } } } diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index e31924a94..49a8e3195 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -1,9 +1,10 @@ import * as express from 'express' import { getServerActor } from '@server/models/application/application' +import { VideosWithSearchCommonQuery } from '@shared/models' import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { getFormattedObjects } from '../../helpers/utils' -import { Hooks } from '../../lib/plugins/hooks' import { JobQueue } from '../../lib/job-queue' +import { Hooks } from '../../lib/plugins/hooks' import { asyncMiddleware, authenticate, @@ -158,25 +159,27 @@ async function listAccountVideos (req: express.Request, res: express.Response) { const account = res.locals.account const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined const countVideos = getCountVideos(req) + const query = req.query as VideosWithSearchCommonQuery const apiOptions = await Hooks.wrapObject({ followerActorId, - start: req.query.start, - count: req.query.count, - sort: req.query.sort, + start: query.start, + count: query.count, + sort: query.sort, includeLocalVideos: true, - categoryOneOf: req.query.categoryOneOf, - licenceOneOf: req.query.licenceOneOf, - languageOneOf: req.query.languageOneOf, - tagsOneOf: req.query.tagsOneOf, - tagsAllOf: req.query.tagsAllOf, - filter: req.query.filter, - nsfw: buildNSFWFilter(res, req.query.nsfw), + categoryOneOf: query.categoryOneOf, + licenceOneOf: query.licenceOneOf, + languageOneOf: query.languageOneOf, + tagsOneOf: query.tagsOneOf, + tagsAllOf: query.tagsAllOf, + filter: query.filter, + isLive: query.isLive, + nsfw: buildNSFWFilter(res, query.nsfw), withFiles: false, accountId: account.id, user: res.locals.oauth ? res.locals.oauth.token.User : undefined, countVideos, - search: req.query.search + search: query.search }, 'filter:api.accounts.videos.list.params') const resultList = await Hooks.wrapPromiseFun( diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 9f9d2d77f..0763d1900 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -111,7 +111,8 @@ async function getUserVideos (req: express.Request, res: express.Response) { start: req.query.start, count: req.query.count, sort: req.query.sort, - search: req.query.search + search: req.query.search, + isLive: req.query.isLive }, 'filter:api.user.me.videos.list.params') const resultList = await Hooks.wrapPromiseFun( diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts index e8949ee59..56b93276f 100644 --- a/server/controllers/api/users/my-subscriptions.ts +++ b/server/controllers/api/users/my-subscriptions.ts @@ -2,8 +2,8 @@ import 'multer' import * as express from 'express' import { sendUndoFollow } from '@server/lib/activitypub/send' import { VideoChannelModel } from '@server/models/video/video-channel' +import { VideosCommonQuery } from '@shared/models' import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' -import { VideoFilter } from '../../../../shared/models/videos/video-query.type' import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' import { getFormattedObjects } from '../../../helpers/utils' import { WEBSERVER } from '../../../initializers/constants' @@ -170,19 +170,20 @@ async function getUserSubscriptions (req: express.Request, res: express.Response async function getUserSubscriptionVideos (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User const countVideos = getCountVideos(req) + const query = req.query as VideosCommonQuery const resultList = await VideoModel.listForApi({ - start: req.query.start, - count: req.query.count, - sort: req.query.sort, + start: query.start, + count: query.count, + sort: query.sort, includeLocalVideos: false, - categoryOneOf: req.query.categoryOneOf, - licenceOneOf: req.query.licenceOneOf, - languageOneOf: req.query.languageOneOf, - tagsOneOf: req.query.tagsOneOf, - tagsAllOf: req.query.tagsAllOf, - nsfw: buildNSFWFilter(res, req.query.nsfw), - filter: req.query.filter as VideoFilter, + categoryOneOf: query.categoryOneOf, + licenceOneOf: query.licenceOneOf, + languageOneOf: query.languageOneOf, + tagsOneOf: query.tagsOneOf, + tagsAllOf: query.tagsAllOf, + nsfw: buildNSFWFilter(res, query.nsfw), + filter: query.filter, withFiles: false, followerActorId: user.Account.Actor.id, user, diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 149d6cfb4..a755d7e57 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -2,7 +2,7 @@ import * as express from 'express' import { Hooks } from '@server/lib/plugins/hooks' import { getServerActor } from '@server/models/application/application' import { MChannelBannerAccountDefault } from '@server/types/models' -import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared' +import { ActorImageType, VideoChannelCreate, VideoChannelUpdate, VideosCommonQuery } from '../../../shared' import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' import { resetSequelizeInstance } from '../../helpers/database-utils' @@ -312,20 +312,21 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon const videoChannelInstance = res.locals.videoChannel const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined const countVideos = getCountVideos(req) + const query = req.query as VideosCommonQuery const apiOptions = await Hooks.wrapObject({ followerActorId, - start: req.query.start, - count: req.query.count, - sort: req.query.sort, + start: query.start, + count: query.count, + sort: query.sort, includeLocalVideos: true, - categoryOneOf: req.query.categoryOneOf, - licenceOneOf: req.query.licenceOneOf, - languageOneOf: req.query.languageOneOf, - tagsOneOf: req.query.tagsOneOf, - tagsAllOf: req.query.tagsAllOf, - filter: req.query.filter, - nsfw: buildNSFWFilter(res, req.query.nsfw), + categoryOneOf: query.categoryOneOf, + licenceOneOf: query.licenceOneOf, + languageOneOf: query.languageOneOf, + tagsOneOf: query.tagsOneOf, + tagsAllOf: query.tagsAllOf, + filter: query.filter, + nsfw: buildNSFWFilter(res, query.nsfw), withFiles: false, videoChannelId: videoChannelInstance.id, user: res.locals.oauth ? res.locals.oauth.token.User : undefined, diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 7fee278f2..6ec6478e4 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -10,9 +10,8 @@ import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnail import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' import { getServerActor } from '@server/models/application/application' import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' -import { VideoCreate, VideoState, VideoUpdate } from '../../../../shared' +import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared' import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' -import { VideoFilter } from '../../../../shared/models/videos/video-query.type' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' @@ -494,20 +493,22 @@ async function getVideoFileMetadata (req: express.Request, res: express.Response } async function listVideos (req: express.Request, res: express.Response) { + const query = req.query as VideosCommonQuery const countVideos = getCountVideos(req) const apiOptions = await Hooks.wrapObject({ - start: req.query.start, - count: req.query.count, - sort: req.query.sort, + start: query.start, + count: query.count, + sort: query.sort, includeLocalVideos: true, - categoryOneOf: req.query.categoryOneOf, - licenceOneOf: req.query.licenceOneOf, - languageOneOf: req.query.languageOneOf, - tagsOneOf: req.query.tagsOneOf, - tagsAllOf: req.query.tagsAllOf, - nsfw: buildNSFWFilter(res, req.query.nsfw), - filter: req.query.filter as VideoFilter, + categoryOneOf: query.categoryOneOf, + licenceOneOf: query.licenceOneOf, + languageOneOf: query.languageOneOf, + tagsOneOf: query.tagsOneOf, + tagsAllOf: query.tagsAllOf, + nsfw: buildNSFWFilter(res, query.nsfw), + isLive: query.isLive, + filter: query.filter, withFiles: false, user: res.locals.oauth ? res.locals.oauth.token.User : undefined, countVideos diff --git a/server/helpers/custom-validators/search.ts b/server/helpers/custom-validators/search.ts index 429fcafcf..a8f258838 100644 --- a/server/helpers/custom-validators/search.ts +++ b/server/helpers/custom-validators/search.ts @@ -11,7 +11,7 @@ function isStringArray (value: any) { return isArray(value) && value.every(v => typeof v === 'string') } -function isNSFWQueryValid (value: any) { +function isBooleanBothQueryValid (value: any) { return value === 'true' || value === 'false' || value === 'both' } @@ -32,6 +32,6 @@ function isSearchTargetValid (value: SearchTargetType) { export { isNumberArray, isStringArray, - isNSFWQueryValid, + isBooleanBothQueryValid, isSearchTargetValid } diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 4d31d3dcb..bb617d77c 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -20,7 +20,7 @@ import { toIntOrNull, toValueOrNull } from '../../../helpers/custom-validators/misc' -import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' +import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership' import { isScheduleVideoUpdatePrivacyValid, @@ -439,7 +439,11 @@ const commonVideosFiltersValidator = [ .custom(isStringArray).withMessage('Should have a valid all of tags array'), query('nsfw') .optional() - .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'), + .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'), + query('isLive') + .optional() + .customSanitizer(toBooleanOrNull) + .custom(isBooleanValid).withMessage('Should have a valid live boolean'), query('filter') .optional() .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'), diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts index 4d95ddee2..155afe64b 100644 --- a/server/models/video/video-query-builder.ts +++ b/server/models/video/video-query-builder.ts @@ -16,9 +16,11 @@ export type BuildVideosQueryOptions = { start: number sort: string - filter?: VideoFilter - categoryOneOf?: number[] nsfw?: boolean + filter?: VideoFilter + isLive?: boolean + + categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] tagsOneOf?: string[] @@ -199,10 +201,14 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) if (options.nsfw === true) { and.push('"video"."nsfw" IS TRUE') + } else if (options.nsfw === false) { + and.push('"video"."nsfw" IS FALSE') } - if (options.nsfw === false) { - and.push('"video"."nsfw" IS FALSE') + if (options.isLive === true) { + and.push('"video"."isLive" IS TRUE') + } else if (options.isLive === false) { + and.push('"video"."isLive" IS FALSE') } if (options.categoryOneOf) { diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 422bf6deb..e55a21a6b 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1021,14 +1021,28 @@ export class VideoModel extends Model { start: number count: number sort: string + isLive?: boolean search?: string }) { - const { accountId, start, count, sort, search } = options + const { accountId, start, count, sort, search, isLive } = options function buildBaseQuery (): FindOptions { - let baseQuery = { + const where: WhereOptions = {} + + if (search) { + where.name = { + [Op.iLike]: '%' + search + '%' + } + } + + if (isLive) { + where.isLive = isLive + } + + const baseQuery = { offset: start, limit: count, + where, order: getVideoSort(sort), include: [ { @@ -1047,16 +1061,6 @@ export class VideoModel extends Model { ] } - if (search) { - baseQuery = Object.assign(baseQuery, { - where: { - name: { - [Op.iLike]: '%' + search + '%' - } - } - }) - } - return baseQuery } @@ -1084,23 +1088,34 @@ export class VideoModel extends Model { start: number count: number sort: string + nsfw: boolean + filter?: VideoFilter + isLive?: boolean + includeLocalVideos: boolean withFiles: boolean + categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] tagsOneOf?: string[] tagsAllOf?: string[] - filter?: VideoFilter + accountId?: number videoChannelId?: number + followerActorId?: number + videoPlaylistId?: number + trendingDays?: number + user?: MUserAccountId historyOfUser?: MUserId + countVideos?: boolean + search?: string }) { if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { @@ -1128,6 +1143,7 @@ export class VideoModel extends Model { followerActorId, serverAccountId: serverActor.Account.id, nsfw: options.nsfw, + isLive: options.isLive, categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, languageOneOf: options.languageOneOf, @@ -1160,6 +1176,7 @@ export class VideoModel extends Model { originallyPublishedStartDate?: string originallyPublishedEndDate?: string nsfw?: boolean + isLive?: boolean categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] @@ -1171,23 +1188,32 @@ export class VideoModel extends Model { filter?: VideoFilter }) { const serverActor = await getServerActor() + const queryOptions = { followerActorId: serverActor.id, serverAccountId: serverActor.Account.id, + includeLocalVideos: options.includeLocalVideos, nsfw: options.nsfw, + isLive: options.isLive, + categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, languageOneOf: options.languageOneOf, + tagsOneOf: options.tagsOneOf, tagsAllOf: options.tagsAllOf, + user: options.user, filter: options.filter, + start: options.start, count: options.count, sort: options.sort, + startDate: options.startDate, endDate: options.endDate, + originallyPublishedStartDate: options.originallyPublishedStartDate, originallyPublishedEndDate: options.originallyPublishedEndDate, diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index d48e2a8ee..57fb58150 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts @@ -19,10 +19,12 @@ import { doubleFollow, flushAndRunMultipleServers, getLive, + getMyVideosWithFilter, getPlaylist, getVideo, getVideoIdFromUUID, getVideosList, + getVideosWithFilters, killallServers, makeRawRequest, removeVideo, @@ -37,6 +39,7 @@ import { testImage, updateCustomSubConfig, updateLive, + uploadVideoAndGetId, viewVideo, wait, waitJobs, @@ -229,6 +232,68 @@ describe('Test live', function () { }) }) + describe('Live filters', function () { + let command: any + let liveVideoId: string + let vodVideoId: string + + before(async function () { + this.timeout(120000) + + vodVideoId = (await uploadVideoAndGetId({ server: servers[0], videoName: 'vod video' })).uuid + + const liveOptions = { name: 'live', privacy: VideoPrivacy.PUBLIC, channelId: servers[0].videoChannel.id } + const resLive = await createLive(servers[0].url, servers[0].accessToken, liveOptions) + liveVideoId = resLive.body.video.uuid + + command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId) + await waitUntilLivePublishedOnAllServers(liveVideoId) + await waitJobs(servers) + }) + + it('Should only display lives', async function () { + const res = await getVideosWithFilters(servers[0].url, { isLive: true }) + + expect(res.body.total).to.equal(1) + expect(res.body.data).to.have.lengthOf(1) + expect(res.body.data[0].name).to.equal('live') + }) + + it('Should not display lives', async function () { + const res = await getVideosWithFilters(servers[0].url, { isLive: false }) + + expect(res.body.total).to.equal(1) + expect(res.body.data).to.have.lengthOf(1) + expect(res.body.data[0].name).to.equal('vod video') + }) + + it('Should display my lives', async function () { + this.timeout(60000) + + await stopFfmpeg(command) + await waitJobs(servers) + + const res = await getMyVideosWithFilter(servers[0].url, servers[0].accessToken, { isLive: true }) + const videos = res.body.data as Video[] + + const result = videos.every(v => v.isLive) + expect(result).to.be.true + }) + + it('Should not display my lives', async function () { + const res = await getMyVideosWithFilter(servers[0].url, servers[0].accessToken, { isLive: false }) + const videos = res.body.data as Video[] + + const result = videos.every(v => !v.isLive) + expect(result).to.be.true + }) + + after(async function () { + await removeVideo(servers[0].url, servers[0].accessToken, vodVideoId) + await removeVideo(servers[0].url, servers[0].accessToken, liveVideoId) + }) + }) + describe('Stream checks', function () { let liveVideo: LiveVideo & VideoDetails let rtmpUrl: string diff --git a/server/tests/api/search/search-videos.ts b/server/tests/api/search/search-videos.ts index e05c3a269..5b8907961 100644 --- a/server/tests/api/search/search-videos.ts +++ b/server/tests/api/search/search-videos.ts @@ -1,17 +1,24 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import * as chai from 'chai' import 'mocha' +import * as chai from 'chai' +import { VideoPrivacy } from '@shared/models' import { advancedVideosSearch, cleanupTests, + createLive, flushAndRunServer, immutableAssign, searchVideo, + sendRTMPStreamInVideo, ServerInfo, setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + updateCustomSubConfig, uploadVideo, - wait + wait, + waitUntilLivePublished } from '../../../../shared/extra-utils' import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions' @@ -28,6 +35,7 @@ describe('Test videos search', function () { server = await flushAndRunServer(1) await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) { const attributes1 = { @@ -449,6 +457,43 @@ describe('Test videos search', function () { expect(res.body.data[0].name).to.equal('1111 2222 3333 - 3') }) + it('Should search by live', async function () { + this.timeout(30000) + + { + const options = { + search: { + searchIndex: { enabled: false } + }, + live: { enabled: true } + } + await updateCustomSubConfig(server.url, server.accessToken, options) + } + + { + const res = await advancedVideosSearch(server.url, { isLive: true }) + + expect(res.body.total).to.equal(0) + expect(res.body.data).to.have.lengthOf(0) + } + + { + const liveOptions = { name: 'live', privacy: VideoPrivacy.PUBLIC, channelId: server.videoChannel.id } + const resLive = await createLive(server.url, server.accessToken, liveOptions) + const liveVideoId = resLive.body.video.uuid + + const command = await sendRTMPStreamInVideo(server.url, server.accessToken, liveVideoId) + await waitUntilLivePublished(server.url, server.accessToken, liveVideoId) + + const res = await advancedVideosSearch(server.url, { isLive: true }) + + expect(res.body.total).to.equal(1) + expect(res.body.data[0].name).to.equal('live') + + await stopFfmpeg(command) + } + }) + after(async function () { await cleanupTests([ server ]) }) diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts index da90223b8..a79648bf7 100644 --- a/server/tests/api/videos/single-server.ts +++ b/server/tests/api/videos/single-server.ts @@ -387,11 +387,11 @@ describe('Test a single server', function () { }) it('Should filter by tags and category', async function () { - const res1 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: 4 }) + const res1 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 4 ] }) expect(res1.body.total).to.equal(1) expect(res1.body.data[0].name).to.equal('my super video updated') - const res2 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: 3 }) + const res2 = await getVideosWithFilters(server.url, { tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 3 ] }) expect(res2.body.total).to.equal(0) }) diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts index 67fe82d41..a0143b0ef 100644 --- a/shared/extra-utils/videos/videos.ts +++ b/shared/extra-utils/videos/videos.ts @@ -8,6 +8,7 @@ import * as request from 'supertest' import { v4 as uuidv4 } from 'uuid' import validator from 'validator' import { HttpStatusCode } from '@shared/core-utils' +import { VideosCommonQuery } from '@shared/models' import { loadLanguages, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../server/initializers/constants' import { VideoDetails, VideoPrivacy } from '../../models/videos' import { @@ -195,6 +196,18 @@ function getMyVideos (url: string, accessToken: string, start: number, count: nu .expect('Content-Type', /json/) } +function getMyVideosWithFilter (url: string, accessToken: string, query: { isLive?: boolean }) { + const path = '/api/v1/users/me/videos' + + return makeGetRequest({ + url, + path, + token: accessToken, + query, + statusCodeExpected: HttpStatusCode.OK_200 + }) +} + function getAccountVideos ( url: string, accessToken: string, @@ -295,7 +308,7 @@ function getVideosListSort (url: string, sort: string) { .expect('Content-Type', /json/) } -function getVideosWithFilters (url: string, query: { tagsAllOf: string[], categoryOneOf: number[] | number }) { +function getVideosWithFilters (url: string, query: VideosCommonQuery) { const path = '/api/v1/videos' return request(url) @@ -751,6 +764,7 @@ export { completeVideoCheck, checkVideoFilesWereRemoved, getPlaylistVideos, + getMyVideosWithFilter, uploadVideoAndGetId, getLocalIdByUUID, getVideoIdFromUUID diff --git a/shared/models/search/boolean-both-query.model.ts b/shared/models/search/boolean-both-query.model.ts new file mode 100644 index 000000000..57b0e8d44 --- /dev/null +++ b/shared/models/search/boolean-both-query.model.ts @@ -0,0 +1 @@ +export type BooleanBothQuery = 'true' | 'false' | 'both' diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts index e2d0ab620..697ceccb1 100644 --- a/shared/models/search/index.ts +++ b/shared/models/search/index.ts @@ -1,4 +1,5 @@ -export * from './nsfw-query.model' +export * from './boolean-both-query.model' export * from './search-target-query.model' +export * from './videos-common-query.model' export * from './videos-search-query.model' export * from './video-channels-search-query.model' diff --git a/shared/models/search/nsfw-query.model.ts b/shared/models/search/nsfw-query.model.ts deleted file mode 100644 index 6b6ad1991..000000000 --- a/shared/models/search/nsfw-query.model.ts +++ /dev/null @@ -1 +0,0 @@ -export type NSFWQuery = 'true' | 'false' | 'both' diff --git a/shared/models/search/videos-common-query.model.ts b/shared/models/search/videos-common-query.model.ts new file mode 100644 index 000000000..bd02489ea --- /dev/null +++ b/shared/models/search/videos-common-query.model.ts @@ -0,0 +1,28 @@ +import { VideoFilter } from '../videos' +import { BooleanBothQuery } from './boolean-both-query.model' + +// These query parameters can be used with any endpoint that list videos +export interface VideosCommonQuery { + start?: number + count?: number + sort?: string + + nsfw?: BooleanBothQuery + + isLive?: boolean + + categoryOneOf?: number[] + + licenceOneOf?: number[] + + languageOneOf?: string[] + + tagsOneOf?: string[] + tagsAllOf?: string[] + + filter?: VideoFilter +} + +export interface VideosWithSearchCommonQuery extends VideosCommonQuery { + search?: string +} diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts index 3ce4ff73e..406f6cab2 100644 --- a/shared/models/search/videos-search-query.model.ts +++ b/shared/models/search/videos-search-query.model.ts @@ -1,33 +1,15 @@ -import { VideoFilter } from '../videos' -import { NSFWQuery } from './nsfw-query.model' import { SearchTargetQuery } from './search-target-query.model' +import { VideosCommonQuery } from './videos-common-query.model' -export interface VideosSearchQuery extends SearchTargetQuery { +export interface VideosSearchQuery extends SearchTargetQuery, VideosCommonQuery { search?: string - start?: number - count?: number - sort?: string - startDate?: string // ISO 8601 endDate?: string // ISO 8601 originallyPublishedStartDate?: string // ISO 8601 originallyPublishedEndDate?: string // ISO 8601 - nsfw?: NSFWQuery - - categoryOneOf?: number[] - - licenceOneOf?: number[] - - languageOneOf?: string[] - - tagsOneOf?: string[] - tagsAllOf?: string[] - durationMin?: number // seconds durationMax?: number // seconds - - filter?: VideoFilter } diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 1fffe7ddf..da51732ad 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -210,6 +210,7 @@ paths: parameters: - $ref: '#/components/parameters/name' - $ref: '#/components/parameters/categoryOneOf' + - $ref: '#/components/parameters/isLive' - $ref: '#/components/parameters/tagsOneOf' - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' @@ -781,6 +782,7 @@ paths: - Videos parameters: - $ref: '#/components/parameters/categoryOneOf' + - $ref: '#/components/parameters/isLive' - $ref: '#/components/parameters/tagsOneOf' - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' @@ -1086,6 +1088,7 @@ paths: - Video parameters: - $ref: '#/components/parameters/categoryOneOf' + - $ref: '#/components/parameters/isLive' - $ref: '#/components/parameters/tagsOneOf' - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' @@ -2194,6 +2197,7 @@ paths: parameters: - $ref: '#/components/parameters/channelHandle' - $ref: '#/components/parameters/categoryOneOf' + - $ref: '#/components/parameters/isLive' - $ref: '#/components/parameters/tagsOneOf' - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' @@ -2841,6 +2845,7 @@ paths: schema: type: string - $ref: '#/components/parameters/categoryOneOf' + - $ref: '#/components/parameters/isLive' - $ref: '#/components/parameters/tagsOneOf' - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' @@ -3809,6 +3814,13 @@ components: description: The comment id schema: type: integer + isLive: + name: isLive + in: query + required: false + description: whether or not the video is a live + schema: + type: boolean categoryOneOf: name: categoryOneOf in: query