diff options
8 files changed, 85 insertions, 11 deletions
diff --git a/client/src/app/menu/avatar-notification.component.scss b/client/src/app/menu/avatar-notification.component.scss index 2ca7f24dc..ebb2a5424 100644 --- a/client/src/app/menu/avatar-notification.component.scss +++ b/client/src/app/menu/avatar-notification.component.scss | |||
@@ -34,6 +34,7 @@ | |||
34 | 34 | ||
35 | & > my-user-notifications:nth-child(2) { | 35 | & > my-user-notifications:nth-child(2) { |
36 | overflow-y: auto; | 36 | overflow-y: auto; |
37 | flex-grow: 1; | ||
37 | } | 38 | } |
38 | } | 39 | } |
39 | 40 | ||
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html index 0cc8af345..ba30953b9 100644 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html | |||
@@ -41,6 +41,10 @@ | |||
41 | </div> | 41 | </div> |
42 | </div> | 42 | </div> |
43 | 43 | ||
44 | <div class="input-container"> | ||
45 | <input type="text" placeholder="Search playlists" [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" /> | ||
46 | </div> | ||
47 | |||
44 | <div class="playlists"> | 48 | <div class="playlists"> |
45 | <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)"> | 49 | <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)"> |
46 | <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox> | 50 | <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox> |
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss index 090b530cf..5f9bb51a7 100644 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss | |||
@@ -53,6 +53,15 @@ | |||
53 | overflow-y: auto; | 53 | overflow-y: auto; |
54 | } | 54 | } |
55 | 55 | ||
56 | .input-container { | ||
57 | display: flex; | ||
58 | |||
59 | input { | ||
60 | flex-grow: 1; | ||
61 | margin: 0 15px 10px 15px; | ||
62 | } | ||
63 | } | ||
64 | |||
56 | .playlist { | 65 | .playlist { |
57 | display: flex; | 66 | display: flex; |
58 | cursor: pointer; | 67 | cursor: pointer; |
@@ -76,7 +85,6 @@ | |||
76 | .new-playlist-button, | 85 | .new-playlist-button, |
77 | .new-playlist-block { | 86 | .new-playlist-block { |
78 | padding-top: 10px; | 87 | padding-top: 10px; |
79 | margin-top: 10px; | ||
80 | border-top: 1px solid $separator-border-color; | 88 | border-top: 1px solid $separator-border-color; |
81 | } | 89 | } |
82 | 90 | ||
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts index 6380c2e51..25ba8cbca 100644 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts | |||
@@ -1,7 +1,8 @@ | |||
1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' | 1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' |
2 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | 2 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' |
3 | import { AuthService, Notifier } from '@app/core' | 3 | import { AuthService, Notifier } from '@app/core' |
4 | import { forkJoin } from 'rxjs' | 4 | import { forkJoin, Subject } from 'rxjs' |
5 | import { debounceTime } from 'rxjs/operators' | ||
5 | import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' | 6 | import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' |
6 | import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' | 7 | import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
@@ -29,6 +30,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, | |||
29 | @Input() lazyLoad = false | 30 | @Input() lazyLoad = false |
30 | 31 | ||
31 | isNewPlaylistBlockOpened = false | 32 | isNewPlaylistBlockOpened = false |
33 | videoPlaylistSearch: string | ||
34 | videoPlaylistSearchChanged = new Subject<string>() | ||
32 | videoPlaylists: PlaylistSummary[] = [] | 35 | videoPlaylists: PlaylistSummary[] = [] |
33 | timestampOptions: { | 36 | timestampOptions: { |
34 | startTimestampEnabled: boolean | 37 | startTimestampEnabled: boolean |
@@ -58,6 +61,13 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, | |||
58 | this.buildForm({ | 61 | this.buildForm({ |
59 | displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME | 62 | displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME |
60 | }) | 63 | }) |
64 | |||
65 | this.videoPlaylistSearchChanged | ||
66 | .pipe( | ||
67 | debounceTime(500)) | ||
68 | .subscribe(() => { | ||
69 | this.load() | ||
70 | }) | ||
61 | } | 71 | } |
62 | 72 | ||
63 | ngOnChanges (simpleChanges: SimpleChanges) { | 73 | ngOnChanges (simpleChanges: SimpleChanges) { |
@@ -74,6 +84,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, | |||
74 | 84 | ||
75 | reload () { | 85 | reload () { |
76 | this.videoPlaylists = [] | 86 | this.videoPlaylists = [] |
87 | this.videoPlaylistSearch = undefined | ||
77 | 88 | ||
78 | this.init() | 89 | this.init() |
79 | 90 | ||
@@ -82,11 +93,12 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, | |||
82 | 93 | ||
83 | load () { | 94 | load () { |
84 | forkJoin([ | 95 | forkJoin([ |
85 | this.videoPlaylistService.listAccountPlaylists(this.user.account, undefined,'-updatedAt'), | 96 | this.videoPlaylistService.listAccountPlaylists(this.user.account, undefined, '-updatedAt', this.videoPlaylistSearch), |
86 | this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id) | 97 | this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id) |
87 | ]) | 98 | ]) |
88 | .subscribe( | 99 | .subscribe( |
89 | ([ playlistsResult, existResult ]) => { | 100 | ([ playlistsResult, existResult ]) => { |
101 | this.videoPlaylists = [] | ||
90 | for (const playlist of playlistsResult.data) { | 102 | for (const playlist of playlistsResult.data) { |
91 | const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id) | 103 | const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id) |
92 | 104 | ||
@@ -178,6 +190,10 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, | |||
178 | return `(${start}-${stop})` | 190 | return `(${start}-${stop})` |
179 | } | 191 | } |
180 | 192 | ||
193 | onVideoPlaylistSearchChanged () { | ||
194 | this.videoPlaylistSearchChanged.next() | ||
195 | } | ||
196 | |||
181 | private removeVideoFromPlaylist (playlist: PlaylistSummary) { | 197 | private removeVideoFromPlaylist (playlist: PlaylistSummary) { |
182 | if (!playlist.playlistElementId) return | 198 | if (!playlist.playlistElementId) return |
183 | 199 | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts index 2945b4959..5f74dcd4c 100644 --- a/client/src/app/shared/video-playlist/video-playlist.service.ts +++ b/client/src/app/shared/video-playlist/video-playlist.service.ts | |||
@@ -59,7 +59,12 @@ export class VideoPlaylistService { | |||
59 | ) | 59 | ) |
60 | } | 60 | } |
61 | 61 | ||
62 | listAccountPlaylists (account: Account, componentPagination: ComponentPagination, sort: string): Observable<ResultList<VideoPlaylist>> { | 62 | listAccountPlaylists ( |
63 | account: Account, | ||
64 | componentPagination: ComponentPagination, | ||
65 | sort: string, | ||
66 | search?: string | ||
67 | ): Observable<ResultList<VideoPlaylist>> { | ||
63 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' | 68 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' |
64 | const pagination = componentPagination | 69 | const pagination = componentPagination |
65 | ? this.restService.componentPaginationToRestPagination(componentPagination) | 70 | ? this.restService.componentPaginationToRestPagination(componentPagination) |
@@ -67,6 +72,7 @@ export class VideoPlaylistService { | |||
67 | 72 | ||
68 | let params = new HttpParams() | 73 | let params = new HttpParams() |
69 | params = this.restService.addRestGetParams(params, pagination, sort) | 74 | params = this.restService.addRestGetParams(params, pagination, sort) |
75 | if (search) params = this.restService.addObjectParams(params, { search }) | ||
70 | 76 | ||
71 | return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params }) | 77 | return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params }) |
72 | .pipe( | 78 | .pipe( |
@@ -213,8 +219,8 @@ export class VideoPlaylistService { | |||
213 | 219 | ||
214 | private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> { | 220 | private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> { |
215 | const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' | 221 | const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' |
216 | let params = new HttpParams() | ||
217 | 222 | ||
223 | let params = new HttpParams() | ||
218 | params = this.restService.addObjectParams(params, { videoIds }) | 224 | params = this.restService.addObjectParams(params, { videoIds }) |
219 | 225 | ||
220 | return this.authHttp.get<VideoExistInPlaylist>(url, { params }) | 226 | return this.authHttp.get<VideoExistInPlaylist>(url, { params }) |
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 5a1d652f2..c49da3c0a 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts | |||
@@ -27,7 +27,10 @@ import { VideoChannelModel } from '../../models/video/video-channel' | |||
27 | import { JobQueue } from '../../lib/job-queue' | 27 | import { JobQueue } from '../../lib/job-queue' |
28 | import { logger } from '../../helpers/logger' | 28 | import { logger } from '../../helpers/logger' |
29 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | 29 | import { VideoPlaylistModel } from '../../models/video/video-playlist' |
30 | import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' | 30 | import { |
31 | commonVideoPlaylistFiltersValidator, | ||
32 | videoPlaylistsSearchValidator | ||
33 | } from '../../middlewares/validators/videos/video-playlists' | ||
31 | 34 | ||
32 | const accountsRouter = express.Router() | 35 | const accountsRouter = express.Router() |
33 | 36 | ||
@@ -72,6 +75,7 @@ accountsRouter.get('/:accountName/video-playlists', | |||
72 | setDefaultSort, | 75 | setDefaultSort, |
73 | setDefaultPagination, | 76 | setDefaultPagination, |
74 | commonVideoPlaylistFiltersValidator, | 77 | commonVideoPlaylistFiltersValidator, |
78 | videoPlaylistsSearchValidator, | ||
75 | asyncMiddleware(listAccountPlaylists) | 79 | asyncMiddleware(listAccountPlaylists) |
76 | ) | 80 | ) |
77 | 81 | ||
@@ -135,6 +139,7 @@ async function listAccountPlaylists (req: express.Request, res: express.Response | |||
135 | } | 139 | } |
136 | 140 | ||
137 | const resultList = await VideoPlaylistModel.listForApi({ | 141 | const resultList = await VideoPlaylistModel.listForApi({ |
142 | search: req.query.search, | ||
138 | followerActorId: serverActor.id, | 143 | followerActorId: serverActor.id, |
139 | start: req.query.start, | 144 | start: req.query.start, |
140 | count: req.query.count, | 145 | count: req.query.count, |
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index 27ee62b1f..1d67e8666 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts | |||
@@ -166,6 +166,18 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => { | |||
166 | ] | 166 | ] |
167 | } | 167 | } |
168 | 168 | ||
169 | const videoPlaylistsSearchValidator = [ | ||
170 | query('search').optional().not().isEmpty().withMessage('Should have a valid search'), | ||
171 | |||
172 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
173 | logger.debug('Checking videoPlaylists search query', { parameters: req.query }) | ||
174 | |||
175 | if (areValidationErrors(req, res)) return | ||
176 | |||
177 | return next() | ||
178 | } | ||
179 | ] | ||
180 | |||
169 | const videoPlaylistsAddVideoValidator = [ | 181 | const videoPlaylistsAddVideoValidator = [ |
170 | param('playlistId') | 182 | param('playlistId') |
171 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), | 183 | .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), |
@@ -354,6 +366,7 @@ export { | |||
354 | videoPlaylistsUpdateValidator, | 366 | videoPlaylistsUpdateValidator, |
355 | videoPlaylistsDeleteValidator, | 367 | videoPlaylistsDeleteValidator, |
356 | videoPlaylistsGetValidator, | 368 | videoPlaylistsGetValidator, |
369 | videoPlaylistsSearchValidator, | ||
357 | 370 | ||
358 | videoPlaylistsAddVideoValidator, | 371 | videoPlaylistsAddVideoValidator, |
359 | videoPlaylistsUpdateOrRemoveVideoValidator, | 372 | videoPlaylistsUpdateOrRemoveVideoValidator, |
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 278d80ac0..ef87a7ee9 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts | |||
@@ -13,10 +13,11 @@ import { | |||
13 | Model, | 13 | Model, |
14 | Scopes, | 14 | Scopes, |
15 | Table, | 15 | Table, |
16 | UpdatedAt | 16 | UpdatedAt, |
17 | Sequelize | ||
17 | } from 'sequelize-typescript' | 18 | } from 'sequelize-typescript' |
18 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | 19 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' |
19 | import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils' | 20 | import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid, createSimilarityAttribute } from '../utils' |
20 | import { | 21 | import { |
21 | isVideoPlaylistDescriptionValid, | 22 | isVideoPlaylistDescriptionValid, |
22 | isVideoPlaylistNameValid, | 23 | isVideoPlaylistNameValid, |
@@ -67,7 +68,8 @@ type AvailableForListOptions = { | |||
67 | type?: VideoPlaylistType | 68 | type?: VideoPlaylistType |
68 | accountId?: number | 69 | accountId?: number |
69 | videoChannelId?: number | 70 | videoChannelId?: number |
70 | privateAndUnlisted?: boolean | 71 | privateAndUnlisted?: boolean, |
72 | search?: string | ||
71 | } | 73 | } |
72 | 74 | ||
73 | @Scopes(() => ({ | 75 | @Scopes(() => ({ |
@@ -163,6 +165,23 @@ type AvailableForListOptions = { | |||
163 | }) | 165 | }) |
164 | } | 166 | } |
165 | 167 | ||
168 | if (options.search) { | ||
169 | const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search) | ||
170 | const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%') | ||
171 | whereAnd.push({ | ||
172 | id: { | ||
173 | [ Op.in ]: Sequelize.literal( | ||
174 | '(' + | ||
175 | 'SELECT "videoPlaylist"."id" FROM "videoPlaylist" ' + | ||
176 | 'WHERE ' + | ||
177 | 'lower(immutable_unaccent("videoPlaylist"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + | ||
178 | 'lower(immutable_unaccent("videoPlaylist"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + | ||
179 | ')' | ||
180 | ) | ||
181 | } | ||
182 | }) | ||
183 | } | ||
184 | |||
166 | const where = { | 185 | const where = { |
167 | [Op.and]: whereAnd | 186 | [Op.and]: whereAnd |
168 | } | 187 | } |
@@ -291,7 +310,8 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
291 | type?: VideoPlaylistType, | 310 | type?: VideoPlaylistType, |
292 | accountId?: number, | 311 | accountId?: number, |
293 | videoChannelId?: number, | 312 | videoChannelId?: number, |
294 | privateAndUnlisted?: boolean | 313 | privateAndUnlisted?: boolean, |
314 | search?: string | ||
295 | }) { | 315 | }) { |
296 | const query = { | 316 | const query = { |
297 | offset: options.start, | 317 | offset: options.start, |
@@ -308,7 +328,8 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
308 | followerActorId: options.followerActorId, | 328 | followerActorId: options.followerActorId, |
309 | accountId: options.accountId, | 329 | accountId: options.accountId, |
310 | videoChannelId: options.videoChannelId, | 330 | videoChannelId: options.videoChannelId, |
311 | privateAndUnlisted: options.privateAndUnlisted | 331 | privateAndUnlisted: options.privateAndUnlisted, |
332 | search: options.search | ||
312 | } as AvailableForListOptions | 333 | } as AvailableForListOptions |
313 | ] | 334 | ] |
314 | }, | 335 | }, |