Browse Source

Allow users/visitors to search through an account's videos (#3589)

* WIP: account search

* add search to account view

* add tests for account search
Rigel Kent 3 years ago
parent
commit
370240824e

+ 104 - 0
client/src/app/+accounts/account-search/account-search.component.ts

@@ -0,0 +1,104 @@
+import { Subscription } from 'rxjs'
+import { first, tap } from 'rxjs/operators'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { Account, AccountService, VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { VideoFilter } from '@shared/models'
+
+@Component({
+  selector: 'my-account-search',
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
+  styleUrls: [
+    '../../shared/shared-video-miniature/abstract-video-list.scss'
+  ]
+})
+export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
+  titlePage: string
+  loadOnInit = false
+
+  search = ''
+  filter: VideoFilter = null
+
+  private account: Account
+  private accountSub: Subscription
+
+  constructor (
+    protected router: Router,
+    protected serverService: ServerService,
+    protected route: ActivatedRoute,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected notifier: Notifier,
+    protected confirmService: ConfirmService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    private accountService: AccountService,
+    private videoService: VideoService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+
+    this.enableAllFilterIfPossible()
+
+    // Parent get the account for us
+    this.accountSub = this.accountService.accountLoaded
+                          .pipe(first())
+                          .subscribe(account => {
+                            this.account = account
+
+                            this.reloadVideos()
+                            this.generateSyndicationList()
+                          })
+  }
+
+  ngOnDestroy () {
+    if (this.accountSub) this.accountSub.unsubscribe()
+
+    super.ngOnDestroy()
+  }
+
+  updateSearch (value: string) {
+    if (value === '') this.router.navigate(['../videos'], { relativeTo: this.route })
+    this.search = value
+
+    this.reloadVideos()
+  }
+
+  getVideosObservable (page: number) {
+    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+    const options = {
+      account: this.account,
+      videoPagination: newPagination,
+      sort: this.sort,
+      nsfwPolicy: this.nsfwPolicy,
+      videoFilter: this.filter,
+      search: this.search
+    }
+
+    return this.videoService
+               .getAccountVideos(options)
+               .pipe(
+                 tap(({ total }) => {
+                   this.titlePage = this.search
+                     ? $localize`Published ${total} videos matching "${this.search}"`
+                     : $localize`Published ${total} videos`
+                 })
+               )
+  }
+
+  toggleModerationDisplay () {
+    this.filter = this.buildLocalFilter(this.filter, null)
+
+    this.reloadVideos()
+  }
+
+  generateSyndicationList () {
+    /* disable syndication */
+  }
+}

+ 22 - 12
client/src/app/+accounts/accounts-routing.module.ts

@@ -5,6 +5,7 @@ import { AccountsComponent } from './accounts.component'
 import { AccountVideosComponent } from './account-videos/account-videos.component'
 import { AccountAboutComponent } from './account-about/account-about.component'
 import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
+import { AccountSearchComponent } from './account-search/account-search.component'
 
 const accountsRoutes: Routes = [
   {
@@ -21,6 +22,24 @@ const accountsRoutes: Routes = [
         redirectTo: 'video-channels',
         pathMatch: 'full'
       },
+      {
+        path: 'video-channels',
+        component: AccountVideoChannelsComponent,
+        data: {
+          meta: {
+            title: $localize`Account video channels`
+          }
+        }
+      },
+      {
+        path: 'about',
+        component: AccountAboutComponent,
+        data: {
+          meta: {
+            title: $localize`About account`
+          }
+        }
+      },
       {
         path: 'videos',
         component: AccountVideosComponent,
@@ -35,20 +54,11 @@ const accountsRoutes: Routes = [
         }
       },
       {
-        path: 'video-channels',
-        component: AccountVideoChannelsComponent,
+        path: 'search',
+        component: AccountSearchComponent,
         data: {
           meta: {
-            title: $localize`Account video channels`
-          }
-        }
-      },
-      {
-        path: 'about',
-        component: AccountAboutComponent,
-        data: {
-          meta: {
-            title: $localize`About account`
+            title: $localize`Search videos within account`
           }
         }
       }

+ 3 - 1
client/src/app/+accounts/accounts.component.html

@@ -44,11 +44,13 @@
       </ng-template>
 
       <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
+
+      <simple-search-input (searchChanged)="searchChanged($event)" name="search-videos" i18n-placeholder placeholder="Search videos"></simple-search-input>
     </div>
   </div>
 
   <div class="margin-content">
-    <router-outlet></router-outlet>
+    <router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
   </div>
 </div>
 

+ 14 - 0
client/src/app/+accounts/accounts.component.ts

@@ -7,6 +7,7 @@ import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel
 import { AccountReportComponent } from '@app/shared/shared-moderation'
 import { User, UserRight } from '@shared/models'
 import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
+import { AccountSearchComponent } from './account-search/account-search.component'
 
 @Component({
   templateUrl: './accounts.component.html',
@@ -14,6 +15,7 @@ import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
 })
 export class AccountsComponent implements OnInit, OnDestroy {
   @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
+  accountSearch: AccountSearchComponent
 
   account: Account
   accountUser: User
@@ -99,6 +101,18 @@ export class AccountsComponent implements OnInit, OnDestroy {
     return $localize`${count} subscribers`
   }
 
+  onOutletLoaded (component: Component) {
+    if (component instanceof AccountSearchComponent) {
+      this.accountSearch = component
+    } else {
+      this.accountSearch = undefined
+    }
+  }
+
+  searchChanged (search: string) {
+    if (this.accountSearch) this.accountSearch.updateSearch(search)
+  }
+
   private onAccount (account: Account) {
     this.prependModerationActions = undefined
 

+ 3 - 1
client/src/app/+accounts/accounts.module.ts

@@ -8,6 +8,7 @@ import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
 import { AccountAboutComponent } from './account-about/account-about.component'
 import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
 import { AccountVideosComponent } from './account-videos/account-videos.component'
+import { AccountSearchComponent } from './account-search/account-search.component'
 import { AccountsRoutingModule } from './accounts-routing.module'
 import { AccountsComponent } from './accounts.component'
 
@@ -27,7 +28,8 @@ import { AccountsComponent } from './accounts.component'
     AccountsComponent,
     AccountVideosComponent,
     AccountVideoChannelsComponent,
-    AccountAboutComponent
+    AccountAboutComponent,
+    AccountSearchComponent
   ],
 
   exports: [

+ 1 - 0
client/src/app/shared/shared-main/misc/index.ts

@@ -1,3 +1,4 @@
 export * from './help.component'
 export * from './list-overflow.component'
 export * from './top-menu-dropdown.component'
+export * from './simple-search-input.component'

+ 14 - 0
client/src/app/shared/shared-main/misc/simple-search-input.component.html

@@ -0,0 +1,14 @@
+<span>
+  <my-global-icon iconName="search" aria-label="Search" role="button" (click)="showInput()"></my-global-icon>
+
+  <input
+    #ref
+    type="text"
+    [(ngModel)]="value"
+    (focusout)="focusLost()"
+    (keyup.enter)="searchChange()"
+    [hidden]="!shown"
+    [name]="name"
+    [placeholder]="placeholder"
+  >
+</span>

+ 29 - 0
client/src/app/shared/shared-main/misc/simple-search-input.component.scss

@@ -0,0 +1,29 @@
+@import '_variables';
+@import '_mixins';
+
+span {
+  opacity: .6;
+  
+  &:focus-within {
+    opacity: 1;
+  }
+}
+
+my-global-icon {
+  height: 18px;
+  position: relative;
+  top: -2px;
+}
+
+input {
+  @include peertube-input-text(150px);
+
+  height: 22px; // maximum height for the account/video-channels links
+  padding-left: 10px;
+  background-color: transparent;
+  border: none;
+
+  &::placeholder {
+    font-size: 15px;
+  }
+}

+ 54 - 0
client/src/app/shared/shared-main/misc/simple-search-input.component.ts

@@ -0,0 +1,54 @@
+import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { Subject } from 'rxjs'
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+
+@Component({
+  selector: 'simple-search-input',
+  templateUrl: './simple-search-input.component.html',
+  styleUrls: [ './simple-search-input.component.scss' ]
+})
+export class SimpleSearchInputComponent implements OnInit {
+  @ViewChild('ref') input: ElementRef
+
+  @Input() name = 'search'
+  @Input() placeholder = $localize`Search`
+
+  @Output() searchChanged = new EventEmitter<string>()
+
+  value = ''
+  shown: boolean
+
+  private searchSubject = new Subject<string>()
+
+  constructor (
+    private router: Router,
+    private route: ActivatedRoute
+  ) {}
+
+  ngOnInit () {
+    this.searchSubject
+        .pipe(
+          debounceTime(400),
+          distinctUntilChanged()
+        )
+        .subscribe(value => this.searchChanged.emit(value))
+
+    this.searchSubject.next(this.value)
+  }
+
+  showInput () {
+    this.shown = true
+    setTimeout(() => this.input.nativeElement.focus())
+  }
+
+  focusLost () {
+    if (this.value !== '') return
+    this.shown = false
+  }
+
+  searchChange () {
+    this.router.navigate(['./search'], { relativeTo: this.route })
+    this.searchSubject.next(this.value)
+  }
+}

+ 3 - 1
client/src/app/shared/shared-main/shared-main.module.ts

@@ -30,7 +30,7 @@ import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditBu
 import { DateToggleComponent } from './date'
 import { FeedComponent } from './feeds'
 import { LoaderComponent, SmallLoaderComponent } from './loaders'
-import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
+import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent, SimpleSearchInputComponent } from './misc'
 import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
 import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
 import { VideoCaptionService } from './video-caption'
@@ -88,6 +88,7 @@ import { VideoChannelService } from './video-channel'
     HelpComponent,
     ListOverflowComponent,
     TopMenuDropdownComponent,
+    SimpleSearchInputComponent,
 
     UserQuotaComponent,
     UserNotificationsComponent
@@ -140,6 +141,7 @@ import { VideoChannelService } from './video-channel'
     HelpComponent,
     ListOverflowComponent,
     TopMenuDropdownComponent,
+    SimpleSearchInputComponent,
 
     UserQuotaComponent,
     UserNotificationsComponent

+ 6 - 1
client/src/app/shared/shared-main/video/video.service.ts

@@ -140,8 +140,9 @@ export class VideoService implements VideosProvider {
     sort: VideoSortField
     nsfwPolicy?: NSFWPolicyType
     videoFilter?: VideoFilter
+    search?: string
   }): Observable<ResultList<Video>> {
-    const { account, videoPagination, sort, videoFilter, nsfwPolicy } = parameters
+    const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters
 
     const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
 
@@ -156,6 +157,10 @@ export class VideoService implements VideosProvider {
       params = params.set('filter', videoFilter)
     }
 
+    if (search) {
+      params = params.set('search', search)
+    }
+
     return this.authHttp
                .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
                .pipe(

+ 5 - 0
client/src/sass/include/_mixins.scss

@@ -665,6 +665,11 @@
         font-size: 130%;
       }
     }
+
+    list-overflow {
+      display: inline-block;
+      width: max-content;
+    }
   }
 }
 

+ 2 - 1
server/controllers/api/accounts.ts

@@ -175,7 +175,8 @@ async function listAccountVideos (req: express.Request, res: express.Response) {
     withFiles: false,
     accountId: account.id,
     user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
-    countVideos
+    countVideos,
+    search: req.query.search
   }, 'filter:api.accounts.videos.list.params')
 
   const resultList = await Hooks.wrapPromiseFun(

+ 4 - 0
server/middlewares/validators/videos/videos.ts

@@ -6,6 +6,7 @@ import { MVideoFullLight } from '@server/types/models'
 import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
 import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
 import {
+  exists,
   isBooleanValid,
   isDateValid,
   isFileFieldValid,
@@ -444,6 +445,9 @@ const commonVideosFiltersValidator = [
     .optional()
     .customSanitizer(toBooleanOrNull)
     .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
+  query('search')
+    .optional()
+    .custom(exists).withMessage('Should have a valid search'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking commons video filters query', { parameters: req.query })

+ 23 - 4
server/tests/api/users/users-multiple-servers.ts

@@ -34,7 +34,7 @@ describe('Test users with multiple servers', function () {
   let userAvatarFilename: string
 
   before(async function () {
-    this.timeout(120000)
+    this.timeout(120_000)
 
     servers = await flushAndRunMultipleServers(3)
 
@@ -92,7 +92,7 @@ describe('Test users with multiple servers', function () {
   })
 
   it('Should be able to update my description', async function () {
-    this.timeout(10000)
+    this.timeout(10_000)
 
     await updateMyUser({
       url: servers[0].url,
@@ -109,7 +109,7 @@ describe('Test users with multiple servers', function () {
   })
 
   it('Should be able to update my avatar', async function () {
-    this.timeout(10000)
+    this.timeout(10_000)
 
     const fixture = 'avatar2.png'
 
@@ -164,8 +164,27 @@ describe('Test users with multiple servers', function () {
     }
   })
 
+  it('Should search through account videos', async function () {
+    this.timeout(10_000)
+
+    const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'Kami no chikara' })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const res = await getAccountVideos(server.url, server.accessToken, 'user1@localhost:' + servers[0].port, 0, 5, undefined, {
+        search: 'Kami'
+      })
+
+      expect(res.body.total).to.equal(1)
+      expect(res.body.data).to.be.an('array')
+      expect(res.body.data).to.have.lengthOf(1)
+      expect(res.body.data[0].uuid).to.equal(resVideo.body.video.uuid)
+    }
+  })
+
   it('Should remove the user', async function () {
-    this.timeout(10000)
+    this.timeout(10_000)
 
     for (const server of servers) {
       const resAccounts = await getAccountsList(server.url, '-createdAt')

+ 4 - 1
shared/extra-utils/videos/videos.ts

@@ -194,7 +194,10 @@ function getAccountVideos (
   start: number,
   count: number,
   sort?: string,
-  query: { nsfw?: boolean } = {}
+  query: {
+    nsfw?: boolean
+    search?: string
+  } = {}
 ) {
   const path = '/api/v1/accounts/' + accountName + '/videos'