Add language filters in user preferences

This commit is contained in:
Chocobozzz 2019-06-19 14:55:58 +02:00
parent bbe078ba55
commit 3caf77d3b1
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
24 changed files with 443 additions and 150 deletions

View File

@ -7,6 +7,9 @@
<div i18n class="account-title">Profile</div>
<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
<div i18n class="account-title">Video settings</div>
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
<div i18n class="account-title" id="notifications">Notifications</div>
<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
@ -16,8 +19,5 @@
<div i18n class="account-title">Email</div>
<my-account-change-email></my-account-change-email>
<div i18n class="account-title">Video settings</div>
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
<div i18n class="account-title">Danger zone</div>
<my-account-danger-zone [user]="user"></my-account-danger-zone>

View File

@ -15,6 +15,21 @@
</div>
</div>
<div class="form-group">
<label i18n for="videoLanguages">Only display videos in the following languages</label>
<my-help i18n-customHtml
customHtml="In Recently added, Trending, Local and Search pages"
></my-help>
<div>
<p-multiSelect
[options]="languageItems" formControlName="videoLanguages" showToggleAll="true"
[defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
emptyFilterMessage="No results found" i18n-emptyFilterMessage
></p-multiSelect>
</div>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="webTorrentEnabled" formControlName="webTorrentEnabled"

View File

@ -1,11 +1,13 @@
import { Component, Input, OnInit } from '@angular/core'
import { Notifier } from '@app/core'
import { Notifier, ServerService } from '@app/core'
import { UserUpdateMe } from '../../../../../../shared'
import { AuthService } from '../../../core'
import { FormReactive, User, UserService } from '../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { Subject } from 'rxjs'
import { SelectItem } from 'primeng/api'
import { switchMap } from 'rxjs/operators'
@Component({
selector: 'my-account-video-settings',
@ -16,11 +18,14 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
@Input() user: User = null
@Input() userInformationLoaded: Subject<any>
languageItems: SelectItem[] = []
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private notifier: Notifier,
private userService: UserService,
private serverService: ServerService,
private i18n: I18n
) {
super()
@ -30,31 +35,60 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
this.buildForm({
nsfwPolicy: null,
webTorrentEnabled: null,
autoPlayVideo: null
autoPlayVideo: null,
videoLanguages: null
})
this.userInformationLoaded.subscribe(() => {
this.form.patchValue({
nsfwPolicy: this.user.nsfwPolicy,
webTorrentEnabled: this.user.webTorrentEnabled,
autoPlayVideo: this.user.autoPlayVideo === true
})
})
this.serverService.videoLanguagesLoaded
.pipe(switchMap(() => this.userInformationLoaded))
.subscribe(() => {
const languages = this.serverService.getVideoLanguages()
this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ]
this.languageItems = this.languageItems
.concat(languages.map(l => ({ label: l.label, value: l.id })))
const videoLanguages = this.user.videoLanguages
? this.user.videoLanguages
: this.languageItems.map(l => l.value)
this.form.patchValue({
nsfwPolicy: this.user.nsfwPolicy,
webTorrentEnabled: this.user.webTorrentEnabled,
autoPlayVideo: this.user.autoPlayVideo === true,
videoLanguages
})
})
}
updateDetails () {
const nsfwPolicy = this.form.value['nsfwPolicy']
const webTorrentEnabled = this.form.value['webTorrentEnabled']
const autoPlayVideo = this.form.value['autoPlayVideo']
let videoLanguages: string[] = this.form.value['videoLanguages']
if (Array.isArray(videoLanguages)) {
if (videoLanguages.length === this.languageItems.length) {
videoLanguages = null // null means "All"
} else if (videoLanguages.length > 20) {
this.notifier.error('Too many languages are enabled. Please enable them all or stay below 20 enabled languages.')
return
} else if (videoLanguages.length === 0) {
this.notifier.error('You need to enabled at least 1 video language.')
return
}
}
const details: UserUpdateMe = {
nsfwPolicy,
webTorrentEnabled,
autoPlayVideo
autoPlayVideo,
videoLanguages
}
this.userService.updateMyProfile(details).subscribe(
() => {
this.notifier.success(this.i18n('Information updated.'))
this.notifier.success(this.i18n('Video settings updated.'))
this.authService.refreshUserInformation()
},
@ -62,4 +96,12 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
err => this.notifier.error(err.message)
)
}
getDefaultVideoLanguageLabel () {
return this.i18n('No language')
}
getSelectedVideoLanguageLabel () {
return this.i18n('{{\'{0} languages selected')
}
}

View File

@ -25,18 +25,13 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
import {
MyAccountVideoPlaylistCreateComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
import {
MyAccountVideoPlaylistUpdateComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
import { MyAccountVideoPlaylistCreateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
import {
MyAccountVideoPlaylistElementsComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
import { MyAccountVideoPlaylistElementsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
import { MultiSelectModule } from 'primeng/primeng'
@NgModule({
imports: [
@ -46,7 +41,8 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
SharedModule,
TableModule,
InputSwitchModule,
DragDropModule
DragDropModule,
MultiSelectModule
],
declarations: [

View File

@ -18,6 +18,7 @@ export class User implements UserServerModel {
webTorrentEnabled: boolean
autoPlayVideo: boolean
videosHistoryEnabled: boolean
videoLanguages: string[]
videoQuota: number
videoQuotaDaily: number

View File

@ -1,7 +1,7 @@
import { debounceTime } from 'rxjs/operators'
import { debounceTime, first, tap } from 'rxjs/operators'
import { OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { fromEvent, Observable, Subscription } from 'rxjs'
import { fromEvent, Observable, of, Subscription } from 'rxjs'
import { AuthService } from '../../core/auth'
import { ComponentPagination } from '../rest/component-pagination.model'
import { VideoSortField } from './sort-field.type'
@ -32,18 +32,20 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
sort: VideoSortField = '-publishedAt'
categoryOneOf?: number
languageOneOf?: string[]
defaultSort: VideoSortField = '-publishedAt'
syndicationItems: Syndication[] = []
loadOnInit = true
videos: Video[] = []
useUserVideoLanguagePreferences = false
ownerDisplayType: OwnerDisplayType = 'account'
displayModerationBlock = false
titleTooltip: string
displayVideoActions = true
groupByDate = false
videos: Video[] = []
disabled = false
displayOptions: MiniatureDisplayOptions = {
@ -98,7 +100,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
.subscribe(() => this.calcPageSizes())
this.calcPageSizes()
if (this.loadOnInit === true) this.loadMoreVideos()
const loadUserObservable = this.loadUserVideoLanguagesIfNeeded()
if (this.loadOnInit === true) {
loadUserObservable.subscribe(() => this.loadMoreVideos())
}
}
ngOnDestroy () {
@ -245,4 +252,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
}
private loadUserVideoLanguagesIfNeeded () {
if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) {
return of(true)
}
return this.authService.userInformationLoaded
.pipe(
first(),
tap(() => this.languageOneOf = this.user.videoLanguages)
)
}
}

View File

@ -35,12 +35,13 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
export interface VideosProvider {
getVideos (
getVideos (parameters: {
videoPagination: ComponentPagination,
sort: VideoSortField,
filter?: VideoFilter,
categoryOneOf?: number
): Observable<{ videos: Video[], totalVideos: number }>
categoryOneOf?: number,
languageOneOf?: string[]
}): Observable<{ videos: Video[], totalVideos: number }>
}
@Injectable()
@ -206,12 +207,15 @@ export class VideoService implements VideosProvider {
)
}
getVideos (
getVideos (parameters: {
videoPagination: ComponentPagination,
sort: VideoSortField,
filter?: VideoFilter,
categoryOneOf?: number
): Observable<{ videos: Video[], totalVideos: number }> {
categoryOneOf?: number,
languageOneOf?: string[]
}): Observable<{ videos: Video[], totalVideos: number }> {
const { videoPagination, sort, filter, categoryOneOf, languageOneOf } = parameters
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
let params = new HttpParams()
@ -225,6 +229,12 @@ export class VideoService implements VideosProvider {
params = params.set('categoryOneOf', categoryOneOf + '')
}
if (languageOneOf) {
for (const l of languageOneOf) {
params = params.append('languageOneOf[]', l)
}
}
return this.authHttp
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
.pipe(

View File

@ -32,7 +32,7 @@ export class RecentVideosRecommendationService implements RecommendationService
private fetchPage (page: number, recommendation: RecommendationInfo): Observable<Video[]> {
const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 }
const defaultSubscription = this.videos.getVideos(pagination, '-createdAt')
const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' })
.pipe(map(v => v.videos))
if (!recommendation.tags || recommendation.tags.length === 0) return defaultSubscription

View File

@ -21,6 +21,8 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
sort = '-publishedAt' as VideoSortField
filter: VideoFilter = 'local'
useUserVideoLanguagePreferences = true
constructor (
protected i18n: I18n,
protected router: Router,
@ -54,7 +56,13 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getVideos(newPagination, this.sort, this.filter, this.categoryOneOf)
return this.videoService.getVideos({
videoPagination: newPagination,
sort: this.sort,
filter: this.filter,
categoryOneOf: this.categoryOneOf,
languageOneOf: this.languageOneOf
})
}
generateSyndicationList () {

View File

@ -19,6 +19,8 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
sort: VideoSortField = '-publishedAt'
groupByDate = true
useUserVideoLanguagePreferences = true
constructor (
protected i18n: I18n,
protected route: ActivatedRoute,
@ -47,7 +49,13 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getVideos(newPagination, this.sort, undefined, this.categoryOneOf)
return this.videoService.getVideos({
videoPagination: newPagination,
sort: this.sort,
filter: undefined,
categoryOneOf: this.categoryOneOf,
languageOneOf: this.languageOneOf
})
}
generateSyndicationList () {

View File

@ -18,6 +18,8 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
titlePage: string
defaultSort: VideoSortField = '-trending'
useUserVideoLanguagePreferences = true
constructor (
protected i18n: I18n,
protected router: Router,
@ -59,7 +61,13 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getVideos(newPagination, this.sort, undefined, this.categoryOneOf)
return this.videoService.getVideos({
videoPagination: newPagination,
sort: this.sort,
filter: undefined,
categoryOneOf: this.categoryOneOf,
languageOneOf: this.languageOneOf
})
}
generateSyndicationList () {

View File

@ -224,6 +224,20 @@
cursor: pointer;
}
@mixin select-arrow-down {
top: 50%;
right: calc(0% + 15px);
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border: 5px solid rgba(0, 0, 0, 0);
border-top-color: #000;
margin-top: -2px;
z-index: 100;
}
@mixin peertube-select-container ($width) {
padding: 0;
margin: 0;
@ -248,17 +262,7 @@
}
&:after {
top: 50%;
right: calc(0% + 15px);
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border: 5px solid rgba(0, 0, 0, 0);
border-top-color: #000;
margin-top: -2px;
z-index: 100;
@include select-arrow-down;
}
select {

View File

@ -232,6 +232,43 @@ p-table {
}
}
// multiselect customizations
p-multiselect {
.ui-multiselect-label {
font-size: 15px !important;
padding: 4px 30px 4px 12px !important;
$width: 338px;
width: $width !important;
@media screen and (max-width: $width) {
width: 100% !important;
}
}
.pi.pi-chevron-down{
margin-left: 0 !important;
&::after {
@include select-arrow-down;
right: 0;
margin-top: 6px;
}
}
.ui-chkbox-icon {
//position: absolute !important;
width: 18px;
height: 18px;
//left: 0;
//&::after {
// left: -2px !important;
//}
}
}
// PrimeNG calendar tweaks
p-calendar .ui-datepicker {
a {

View File

@ -182,6 +182,7 @@ async function updateMe (req: express.Request, res: express.Response) {
if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
if (body.videoLanguages !== undefined) user.videoLanguages = body.videoLanguages
if (body.email !== undefined) {
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {

View File

@ -2,7 +2,7 @@ import 'express-validator'
import * as validator from 'validator'
import { UserRole } from '../../../shared'
import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants'
import { exists, isBooleanValid, isFileValid } from './misc'
import { exists, isArray, isBooleanValid, isFileValid } from './misc'
import { values } from 'lodash'
const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS
@ -54,6 +54,10 @@ function isUserAutoPlayVideoValid (value: any) {
return isBooleanValid(value)
}
function isUserVideoLanguages (value: any) {
return value === null || (isArray(value) && value.length < CONSTRAINTS_FIELDS.USERS.VIDEO_LANGUAGES.max)
}
function isUserAdminFlagsValid (value: any) {
return exists(value) && validator.isInt('' + value)
}
@ -84,6 +88,7 @@ export {
isUserVideosHistoryEnabledValid,
isUserBlockedValid,
isUserPasswordValid,
isUserVideoLanguages,
isUserBlockedReasonValid,
isUserRoleValid,
isUserVideoQuotaValid,

View File

@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 390
const LAST_MIGRATION_VERSION = 395
// ---------------------------------------------------------------------------
@ -177,6 +177,7 @@ let CONSTRAINTS_FIELDS = {
PASSWORD: { min: 6, max: 255 }, // Length
VIDEO_QUOTA: { min: -1 },
VIDEO_QUOTA_DAILY: { min: -1 },
VIDEO_LANGUAGES: { max: 500 }, // Array length
BLOCKED_REASON: { min: 3, max: 250 } // Length
},
VIDEO_ABUSES: {

View File

@ -0,0 +1,25 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction,
queryInterface: Sequelize.QueryInterface,
sequelize: Sequelize.Sequelize,
db: any
}): Promise<void> {
const data = {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
defaultValue: null
}
await utils.queryInterface.addColumn('user', 'videoLanguages', data)
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -13,7 +13,7 @@ import {
isUserNSFWPolicyValid,
isUserPasswordValid,
isUserRoleValid,
isUserUsernameValid,
isUserUsernameValid, isUserVideoLanguages,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid,
isUserVideosHistoryEnabledValid
@ -198,6 +198,9 @@ const usersUpdateMeValidator = [
body('autoPlayVideo')
.optional()
.custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
body('videoLanguages')
.optional()
.custom(isUserVideoLanguages).withMessage('Should have a valid video languages attribute'),
body('videosHistoryEnabled')
.optional()
.custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),

View File

@ -31,6 +31,7 @@ import {
isUserPasswordValid,
isUserRoleValid,
isUserUsernameValid,
isUserVideoLanguages,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid,
isUserVideosHistoryEnabledValid,
@ -147,6 +148,12 @@ export class UserModel extends Model<UserModel> {
@Column
autoPlayVideo: boolean
@AllowNull(true)
@Default(null)
@Is('UserVideoLanguages', value => throwIfNotValid(value, isUserVideoLanguages, 'video languages'))
@Column(DataType.ARRAY(DataType.STRING))
videoLanguages: string[]
@AllowNull(false)
@Default(UserAdminFlag.NONE)
@Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
@ -551,6 +558,7 @@ export class UserModel extends Model<UserModel> {
webTorrentEnabled: this.webTorrentEnabled,
videosHistoryEnabled: this.videosHistoryEnabled,
autoPlayVideo: this.autoPlayVideo,
videoLanguages: this.videoLanguages,
role: this.role,
roleLabel: USER_ROLE_LABELS[ this.role ],
videoQuota: this.videoQuota,

View File

@ -1,7 +1,7 @@
import { Sequelize } from 'sequelize-typescript'
import { Model, Sequelize } from 'sequelize-typescript'
import * as validator from 'validator'
import { OrderItem } from 'sequelize'
import { Col } from 'sequelize/types/lib/utils'
import { OrderItem } from 'sequelize/types'
type SortType = { sortModel: any, sortValue: string }
@ -127,6 +127,11 @@ function parseAggregateResult (result: any) {
return total
}
const createSafeIn = (model: typeof Model, stringArr: string[]) => {
return stringArr.map(t => model.sequelize.escape(t))
.join(', ')
}
// ---------------------------------------------------------------------------
export {
@ -141,7 +146,8 @@ export {
buildTrigramSearchIndex,
buildWhereIdOrUUID,
isOutdated,
parseAggregateResult
parseAggregateResult,
createSafeIn
}
// ---------------------------------------------------------------------------

View File

@ -83,6 +83,7 @@ import {
buildBlockedAccountSQL,
buildTrigramSearchIndex,
buildWhereIdOrUUID,
createSafeIn,
createSimilarityAttribute,
getVideoSort,
isOutdated,
@ -227,6 +228,8 @@ type AvailableForListIDsOptions = {
trendingDays?: number
user?: UserModel,
historyOfUser?: UserModel
baseWhere?: WhereOptions[]
}
@Scopes(() => ({
@ -270,34 +273,34 @@ type AvailableForListIDsOptions = {
return query
},
[ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
const attributes = options.withoutId === true ? [] : [ 'id' ]
const whereAnd = options.baseWhere ? options.baseWhere : []
const query: FindOptions = {
raw: true,
attributes,
where: {
id: {
[ Op.and ]: [
{
[ Op.notIn ]: Sequelize.literal(
'(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
)
}
]
},
channelId: {
[ Op.notIn ]: Sequelize.literal(
'(' +
'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
')' +
')'
)
}
},
attributes: options.withoutId === true ? [] : [ 'id' ],
include: []
}
whereAnd.push({
id: {
[ Op.notIn ]: Sequelize.literal(
'(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
)
}
})
whereAnd.push({
channelId: {
[ Op.notIn ]: Sequelize.literal(
'(' +
'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
')' +
')'
)
}
})
// Only list public/published videos
if (!options.filter || options.filter !== 'all-local') {
const privacyWhere = {
@ -317,7 +320,7 @@ type AvailableForListIDsOptions = {
]
}
Object.assign(query.where, privacyWhere)
whereAnd.push(privacyWhere)
}
if (options.videoPlaylistId) {
@ -387,86 +390,114 @@ type AvailableForListIDsOptions = {
// Force actorId to be a number to avoid SQL injections
const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
query.where[ 'id' ][ Op.and ].push({
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
' UNION ALL ' +
'SELECT "video"."id" AS "id" FROM "video" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
localVideosReq +
')'
)
whereAnd.push({
id: {
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
' UNION ALL ' +
'SELECT "video"."id" AS "id" FROM "video" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
localVideosReq +
')'
)
}
})
}
if (options.withFiles === true) {
query.where[ 'id' ][ Op.and ].push({
[ Op.in ]: Sequelize.literal(
'(SELECT "videoId" FROM "videoFile")'
)
whereAnd.push({
id: {
[ Op.in ]: Sequelize.literal(
'(SELECT "videoId" FROM "videoFile")'
)
}
})
}
// FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
if (options.tagsAllOf || options.tagsOneOf) {
const createTagsIn = (tags: string[]) => {
return tags.map(t => VideoModel.sequelize.escape(t))
.join(', ')
}
if (options.tagsOneOf) {
query.where[ 'id' ][ Op.and ].push({
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoId" FROM "videoTag" ' +
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' +
')'
)
whereAnd.push({
id: {
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoId" FROM "videoTag" ' +
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsOneOf) + ')' +
')'
)
}
})
}
if (options.tagsAllOf) {
query.where[ 'id' ][ Op.and ].push({
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoId" FROM "videoTag" ' +
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' +
'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
')'
)
whereAnd.push({
id: {
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoId" FROM "videoTag" ' +
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsAllOf) + ')' +
'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
')'
)
}
})
}
}
if (options.nsfw === true || options.nsfw === false) {
query.where[ 'nsfw' ] = options.nsfw
whereAnd.push({ nsfw: options.nsfw })
}
if (options.categoryOneOf) {
query.where[ 'category' ] = {
[ Op.or ]: options.categoryOneOf
}
whereAnd.push({
category: {
[ Op.or ]: options.categoryOneOf
}
})
}
if (options.licenceOneOf) {
query.where[ 'licence' ] = {
[ Op.or ]: options.licenceOneOf
}
whereAnd.push({
licence: {
[ Op.or ]: options.licenceOneOf
}
})
}
if (options.languageOneOf) {
query.where[ 'language' ] = {
[ Op.or ]: options.languageOneOf
let videoLanguages = options.languageOneOf
if (options.languageOneOf.find(l => l === '_unknown')) {
videoLanguages = videoLanguages.concat([ null ])
}
whereAnd.push({
[Op.or]: [
{
language: {
[ Op.or ]: videoLanguages
}
},
{
id: {
[ Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoId" FROM "videoCaption" ' +
'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' +
')'
)
}
}
]
})
}
if (options.trendingDays) {
@ -490,6 +521,10 @@ type AvailableForListIDsOptions = {
query.subQuery = false
}
query.where = {
[ Op.and ]: whereAnd
}
return query
},
[ ScopeNames.WITH_THUMBNAILS ]: {
@ -1175,7 +1210,7 @@ export class VideoModel extends Model<VideoModel> {
throw new Error('Try to filter all-local but no user has not the see all videos right')
}
const query: FindOptions = {
const query: FindOptions & { where?: null } = {
offset: options.start,
limit: options.count,
order: getVideoSort(options.sort)
@ -1299,16 +1334,13 @@ export class VideoModel extends Model<VideoModel> {
)
}
const query: FindOptions = {
const query = {
attributes: {
include: attributesInclude
},
offset: options.start,
limit: options.count,
order: getVideoSort(options.sort),
where: {
[ Op.and ]: whereAnd
}
order: getVideoSort(options.sort)
}
const serverActor = await getServerActor()
@ -1323,7 +1355,8 @@ export class VideoModel extends Model<VideoModel> {
tagsOneOf: options.tagsOneOf,
tagsAllOf: options.tagsAllOf,
user: options.user,
filter: options.filter
filter: options.filter,
baseWhere: whereAnd
}
return VideoModel.getAvailableForApi(query, queryOptions)
@ -1590,7 +1623,7 @@ export class VideoModel extends Model<VideoModel> {
}
private static async getAvailableForApi (
query: FindOptions,
query: FindOptions & { where?: null }, // Forbid where field in query
options: AvailableForListIDsOptions,
countVideos = true
) {
@ -1609,11 +1642,15 @@ export class VideoModel extends Model<VideoModel> {
]
}
const [ count, rowsId ] = await Promise.all([
countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
VideoModel.scope(idsScope).findAll(query)
const [ count, ids ] = await Promise.all([
countVideos
? VideoModel.scope(countScope).count(countQuery)
: Promise.resolve<number>(undefined),
VideoModel.scope(idsScope)
.findAll(query)
.then(rows => rows.map(r => r.id))
])
const ids = rowsId.map(r => r.id)
if (ids.length === 0) return { data: [], total: count }

View File

@ -364,6 +364,29 @@ describe('Test users API validators', function () {
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
})
it('Should fail with an invalid videoLanguages attribute', async function () {
{
const fields = {
videoLanguages: 'toto'
}
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
}
{
const languages = []
for (let i = 0; i < 1000; i++) {
languages.push('fr')
}
const fields = {
videoLanguages: languages
}
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
}
})
it('Should succeed to change password with the correct params', async function () {
const fields = {
currentPassword: 'my super password',

View File

@ -13,6 +13,7 @@ import {
uploadVideo,
wait
} from '../../../../shared/extra-utils'
import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions'
const expect = chai.expect
@ -41,8 +42,29 @@ describe('Test videos search', function () {
const attributes2 = immutableAssign(attributes1, { name: attributes1.name + ' - 2', fixture: 'video_short.mp4' })
await uploadVideo(server.url, server.accessToken, attributes2)
const attributes3 = immutableAssign(attributes1, { name: attributes1.name + ' - 3', language: 'en' })
await uploadVideo(server.url, server.accessToken, attributes3)
{
const attributes3 = immutableAssign(attributes1, { name: attributes1.name + ' - 3', language: undefined })
const res = await uploadVideo(server.url, server.accessToken, attributes3)
const videoId = res.body.video.id
await createVideoCaption({
url: server.url,
accessToken: server.accessToken,
language: 'en',
videoId,
fixture: 'subtitle-good2.vtt',
mimeType: 'application/octet-stream'
})
await createVideoCaption({
url: server.url,
accessToken: server.accessToken,
language: 'aa',
videoId,
fixture: 'subtitle-good2.vtt',
mimeType: 'application/octet-stream'
})
}
const attributes4 = immutableAssign(attributes1, { name: attributes1.name + ' - 4', language: 'pl', nsfw: true })
await uploadVideo(server.url, server.accessToken, attributes4)
@ -51,7 +73,7 @@ describe('Test videos search', function () {
startDate = new Date().toISOString()
const attributes5 = immutableAssign(attributes1, { name: attributes1.name + ' - 5', licence: 2 })
const attributes5 = immutableAssign(attributes1, { name: attributes1.name + ' - 5', licence: 2, language: undefined })
await uploadVideo(server.url, server.accessToken, attributes5)
const attributes6 = immutableAssign(attributes1, { name: attributes1.name + ' - 6', tags: [ 't1', 't2 '] })
@ -241,13 +263,26 @@ describe('Test videos search', function () {
search: '1111 2222 3333',
languageOneOf: [ 'pl', 'en' ]
}
const res1 = await advancedVideosSearch(server.url, query)
expect(res1.body.total).to.equal(2)
expect(res1.body.data[0].name).to.equal('1111 2222 3333 - 3')
expect(res1.body.data[1].name).to.equal('1111 2222 3333 - 4')
const res2 = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'eo' ] }))
expect(res2.body.total).to.equal(0)
{
const res = await advancedVideosSearch(server.url, query)
expect(res.body.total).to.equal(2)
expect(res.body.data[ 0 ].name).to.equal('1111 2222 3333 - 3')
expect(res.body.data[ 1 ].name).to.equal('1111 2222 3333 - 4')
}
{
const res = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'pl', 'en', '_unknown' ] }))
expect(res.body.total).to.equal(3)
expect(res.body.data[ 0 ].name).to.equal('1111 2222 3333 - 3')
expect(res.body.data[ 1 ].name).to.equal('1111 2222 3333 - 4')
expect(res.body.data[ 2 ].name).to.equal('1111 2222 3333 - 5')
}
{
const res = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'eo' ] }))
expect(res.body.total).to.equal(0)
}
})
it('Should search by start date', async function () {

View File

@ -8,6 +8,7 @@ export interface UserUpdateMe {
webTorrentEnabled?: boolean
autoPlayVideo?: boolean
videosHistoryEnabled?: boolean
videoLanguages?: string[]
email?: string
currentPassword?: string