Add ability to search video channels
authorChocobozzz <me@florianbigard.com>
Thu, 23 Aug 2018 15:58:39 +0000 (17:58 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 27 Aug 2018 07:41:54 +0000 (09:41 +0200)
35 files changed:
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
client/src/app/search/search.component.html
client/src/app/search/search.component.scss
client/src/app/search/search.component.ts
client/src/app/search/search.service.ts
client/src/app/shared/rest/rest.service.ts
client/src/app/shared/user-subscription/subscribe-button.component.html
client/src/app/shared/user-subscription/subscribe-button.component.scss
client/src/app/shared/user-subscription/subscribe-button.component.ts
client/src/app/shared/user-subscription/user-subscription.service.ts
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.scss
config/production.yaml.example
server/controllers/api/search.ts
server/controllers/api/users/me.ts
server/controllers/api/video-channel.ts
server/helpers/custom-validators/activitypub/actor.ts
server/initializers/constants.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/videos.ts
server/middlewares/validators/follows.ts
server/middlewares/validators/search.ts
server/middlewares/validators/sort.ts
server/middlewares/validators/user-subscriptions.ts
server/models/account/account.ts
server/models/activitypub/actor-follow.ts
server/models/activitypub/actor.ts
server/models/video/video-channel.ts
server/tests/api/check-params/user-subscriptions.ts
server/tests/api/users/user-subscriptions.ts
server/tests/utils/users/user-subscriptions.ts
shared/models/search/index.ts
shared/models/search/video-channels-search-query.model.ts [new file with mode: 0644]

index 2fbfa335b6f7420c5826017c521c2fbcb7260660..8cb0b677dfc64e4ae39b17a1e94a710f0f874d25 100644 (file)
   .actor-owner {
     @include actor-owner;
   }
-
-  my-subscribe-button {
-    /deep/ span[role=button] {
-      padding: 7px 12px;
-      font-size: 16px;
-    }
-  }
 }
 
 
index 1e94cf90b170dd4f6c2d2c1360f099a888179a81..9434b196fc3c160513d3c0bba57ef7e3780dc837 100644 (file)
@@ -21,7 +21,7 @@ export class MyAccountSubscriptionsComponent implements OnInit {
   ngOnInit () {
     this.userSubscriptionService.listSubscriptions()
       .subscribe(
-        res => { console.log(res); this.videoChannels = res.data },
+        res => this.videoChannels = res.data,
 
         error => this.notificationsService.error(this.i18n('Error'), error.message)
       )
index 5c892be012b2089fed84aafcbfcaf2c7a786ae38..83d657f03984b8f816ddddc4c4167113cfbf1613 100644 (file)
         color: $grey-actor-name;
         margin-left: 5px;
       }
+
+      .video-channel-followers {
+
+      }
     }
   }
 
index bbc70f772dd41407e1b0472d30312264ba459535..128cc52f5ade5006ad51e4b0d8abeb5f7b1c7e6a 100644 (file)
     </div>
   </div>
 
-  <div i18n *ngIf="pagination.totalItems === 0" class="no-result">
+  <div i18n *ngIf="pagination.totalItems === 0 && videoChannels.length === 0" class="no-result">
     No results found
   </div>
 
+  <div *ngFor="let videoChannel of videoChannels" class="entry video-channel">
+    <a [routerLink]="[ '/video-channels', videoChannel.name ]">
+      <img [src]="videoChannel.avatarUrl" alt="Avatar" />
+    </a>
+
+    <div class="video-channel-info">
+      <a [routerLink]="[ '/video-channels', videoChannel.name ]" class="video-channel-names">
+        <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
+        <div class="video-channel-name">{{ videoChannel.name }}</div>
+      </a>
+
+      <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div>
+    </div>
+
+    <my-subscribe-button [videoChannel]="videoChannel"></my-subscribe-button>
+  </div>
+
   <div *ngFor="let video of videos" class="entry video">
     <my-video-thumbnail [video]="video"></my-video-thumbnail>
 
index e54a8b32a557f906947652282df23184568ffd7e..be7dd39cf5f32d493cba553e79f6e98d48b3b04f 100644 (file)
         }
       }
     }
+
+    &.video-channel {
+
+      img {
+        @include avatar(120px);
+
+        margin: 0 50px 0 40px;
+      }
+
+      .video-channel-info {
+
+
+        flex-grow: 1;
+        width: fit-content;
+
+        .video-channel-names {
+          @include disable-default-a-behaviour;
+
+          display: flex;
+          align-items: baseline;
+          color: #000;
+          width: fit-content;
+
+          .video-channel-display-name {
+            font-weight: $font-semibold;
+            font-size: 18px;
+          }
+
+          .video-channel-name {
+            font-size: 14px;
+            color: $grey-actor-name;
+            margin-left: 5px;
+          }
+        }
+      }
+    }
   }
 }
 
index 8d615fd058dd990674217300592a632870ab6629..f88df639109f91017885b806e929ad1993f85dad 100644 (file)
@@ -2,13 +2,15 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { RedirectService } from '@app/core'
 import { NotificationsService } from 'angular2-notifications'
-import { Subscription } from 'rxjs'
+import { forkJoin, Subscription } from 'rxjs'
 import { SearchService } from '@app/search/search.service'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { Video } from '../../../../shared'
 import { MetaService } from '@ngx-meta/core'
 import { AdvancedSearch } from '@app/search/advanced-search.model'
+import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { immutableAssign } from '@app/shared/misc/utils'
 
 @Component({
   selector: 'my-search',
@@ -17,18 +19,22 @@ import { AdvancedSearch } from '@app/search/advanced-search.model'
 })
 export class SearchComponent implements OnInit, OnDestroy {
   videos: Video[] = []
+  videoChannels: VideoChannel[] = []
+
   pagination: ComponentPagination = {
     currentPage: 1,
-    itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc)
+    itemsPerPage: 10, // Only for videos, use another variable for channels
     totalItems: null
   }
   advancedSearch: AdvancedSearch = new AdvancedSearch()
   isSearchFilterCollapsed = true
+  currentSearch: string
 
   private subActivatedRoute: Subscription
-  private currentSearch: string
   private isInitialLoad = true
 
+  private channelsPerPage = 2
+
   constructor (
     private i18n: I18n,
     private route: ActivatedRoute,
@@ -74,17 +80,23 @@ export class SearchComponent implements OnInit, OnDestroy {
   }
 
   search () {
-    return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch)
+    forkJoin([
+      this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch),
+      this.searchService.searchVideoChannels(this.currentSearch, immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }))
+    ])
       .subscribe(
-        ({ videos, totalVideos }) => {
-          this.videos = this.videos.concat(videos)
-          this.pagination.totalItems = totalVideos
+        ([ videosResult, videoChannelsResult ]) => {
+          this.videos = this.videos.concat(videosResult.videos)
+          this.pagination.totalItems = videosResult.totalVideos
+
+          this.videoChannels = videoChannelsResult.data
         },
 
         error => {
           this.notificationsService.error(this.i18n('Error'), error.message)
         }
       )
+
   }
 
   onNearOfBottom () {
index a37c4916122bc585b1fb7220a883ae4cdab7fbaf..cd3bdad356443812c0e5429192c16bc80d39d58a 100644 (file)
@@ -1,4 +1,4 @@
-import { catchError, switchMap } from 'rxjs/operators'
+import { catchError, map, switchMap } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Observable } from 'rxjs'
@@ -6,13 +6,11 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model
 import { VideoService } from '@app/shared/video/video.service'
 import { RestExtractor, RestService } from '@app/shared'
 import { environment } from 'environments/environment'
-import { ResultList, Video } from '../../../../shared'
-import { Video as VideoServerModel } from '@app/shared/video/video.model'
+import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
+import { Video } from '@app/shared/video/video.model'
 import { AdvancedSearch } from '@app/search/advanced-search.model'
-
-export type SearchResult = {
-  videosResult: { totalVideos: number, videos: Video[] }
-}
+import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
 
 @Injectable()
 export class SearchService {
@@ -40,17 +38,7 @@ export class SearchService {
     if (search) params = params.append('search', search)
 
     const advancedSearchObject = advancedSearch.toAPIObject()
-
-    for (const name of Object.keys(advancedSearchObject)) {
-      const value = advancedSearchObject[name]
-      if (!value) continue
-
-      if (Array.isArray(value) && value.length !== 0) {
-        for (const v of value) params = params.append(name, v)
-      } else {
-        params = params.append(name, value)
-      }
-    }
+    params = this.restService.addObjectParams(params, advancedSearchObject)
 
     return this.authHttp
                .get<ResultList<VideoServerModel>>(url, { params })
@@ -59,4 +47,24 @@ export class SearchService {
                  catchError(err => this.restExtractor.handleError(err))
                )
   }
+
+  searchVideoChannels (
+    search: string,
+    componentPagination: ComponentPagination
+  ): Observable<{ data: VideoChannel[], total: number }> {
+    const url = SearchService.BASE_SEARCH_URL + 'video-channels'
+
+    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination)
+    params = params.append('search', search)
+
+    return this.authHttp
+               .get<ResultList<VideoChannelServerModel>>(url, { params })
+               .pipe(
+                 map(res => VideoChannelService.extractVideoChannels(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
 }
index 5d5410de98c50deb1345f08b1051937182c66c16..4560c2024983a728ffee0d681eb4fa759e1fef80 100644 (file)
@@ -32,6 +32,21 @@ export class RestService {
     return newParams
   }
 
+  addObjectParams (params: HttpParams, object: object) {
+    for (const name of Object.keys(object)) {
+      const value = object[name]
+      if (!value) continue
+
+      if (Array.isArray(value) && value.length !== 0) {
+        for (const v of value) params = params.append(name, v)
+      } else {
+        params = params.append(name, value)
+      }
+    }
+
+    return params
+  }
+
   componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination {
     const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
     const count: number = componentPagination.itemsPerPage
index 63b313662b7be56ba98661e453bc5e4fc493e3fc..34c024c178428fac05f5100be12950aa70dd63c7 100644 (file)
@@ -1,11 +1,11 @@
-<span i18n *ngIf="subscribed === false" class="subscribe-button" role="button" (click)="subscribe()">
+<span i18n *ngIf="subscribed === false" class="subscribe-button" [ngClass]="size" role="button" (click)="subscribe()">
   <span>Subscribe</span>
   <span *ngIf="displayFollowers && videoChannel.followersCount !== 0" class="followers-count">
     {{ videoChannel.followersCount | myNumberFormatter }}
   </span>
 </span>
 
-<span *ngIf="subscribed === true" class="unsubscribe-button" role="button" (click)="unsubscribe()">
+<span *ngIf="subscribed === true" class="unsubscribe-button" [ngClass]="size" role="button" (click)="unsubscribe()">
   <span class="subscribed" i18n>Subscribed</span>
   <span class="unsubscribe" i18n>Unsubscribe</span>
 
index 9811fdc0c7b75d42be2481a76c75e2bb4c77994b..b78d2f59cd53b27d482341f9172c7fe8a5b7a709 100644 (file)
 
 .subscribe-button,
 .unsubscribe-button {
-  padding: 3px 7px;
+  display: inline-block;
+
+  &.small {
+    min-width: 75px;
+    height: 20px;
+    line-height: 20px;
+    font-size: 13px;
+  }
+
+  &.normal {
+    min-width: 120px;
+    height: 30px;
+    line-height: 30px;
+    font-size: 16px;
+  }
 }
 
 .unsubscribe-button {
index 46d6dbaf7f861253e72b611fce629007ccc8dc27..ba7acf69adf7ef9c0086cc4c8790444dd1709823 100644 (file)
@@ -15,6 +15,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
 export class SubscribeButtonComponent implements OnInit {
   @Input() videoChannel: VideoChannel
   @Input() displayFollowers = false
+  @Input() size: 'small' | 'normal' = 'normal'
 
   subscribed: boolean
 
@@ -34,7 +35,7 @@ export class SubscribeButtonComponent implements OnInit {
   ngOnInit () {
     this.userSubscriptionService.isSubscriptionExists(this.uri)
       .subscribe(
-        exists => this.subscribed = exists,
+        res => this.subscribed = res[this.uri],
 
         err => this.notificationsService.error(this.i18n('Error'), err.message)
       )
index 3103706d1a1830d261629bbb71a9cfa020ff2ecb..cf622019f638e9d05325340c9ece9937e44d44ea 100644 (file)
@@ -1,22 +1,36 @@
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient } from '@angular/common/http'
+import { bufferTime, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { ResultList } from '../../../../../shared'
 import { environment } from '../../../environments/environment'
-import { RestExtractor } from '../rest'
-import { Observable, of } from 'rxjs'
+import { RestExtractor, RestService } from '../rest'
+import { Observable, ReplaySubject, Subject } from 'rxjs'
 import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
 import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
 import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos'
 
+type SubscriptionExistResult = { [ uri: string ]: boolean }
+
 @Injectable()
 export class UserSubscriptionService {
   static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions'
 
+  // Use a replay subject because we "next" a value before subscribing
+  private existsSubject: Subject<string> = new ReplaySubject(1)
+  private existsObservable: Observable<SubscriptionExistResult>
+
   constructor (
     private authHttp: HttpClient,
-    private restExtractor: RestExtractor
+    private restExtractor: RestExtractor,
+    private restService: RestService
   ) {
+    this.existsObservable = this.existsSubject.pipe(
+      tap(u => console.log(u)),
+      bufferTime(500),
+      filter(uris => uris.length !== 0),
+      switchMap(uris => this.areSubscriptionExist(uris)),
+      share()
+    )
   }
 
   deleteSubscription (nameWithHost: string) {
@@ -50,17 +64,20 @@ export class UserSubscriptionService {
                )
   }
 
-  isSubscriptionExists (nameWithHost: string): Observable<boolean> {
-    const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost
+  isSubscriptionExists (nameWithHost: string) {
+    this.existsSubject.next(nameWithHost)
 
-    return this.authHttp.get(url)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => {
-                   if (err.status === 404) return of(false)
+    return this.existsObservable
+  }
 
-                   return this.restExtractor.handleError(err)
-                 })
-               )
+  private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> {
+    console.log(uris)
+    const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
+    let params = new HttpParams()
+
+    params = this.restService.addObjectParams(params, { uris })
+
+    return this.authHttp.get<SubscriptionExistResult>(url, { params })
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
 }
index 8a49e3566f9eb8396fc2ab966d56a8e01d4eef5f..e9c79741ed52fbb2109b5030d90019f678dfdf81 100644 (file)
@@ -43,7 +43,7 @@
               <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" />
             </a>
 
-            <my-subscribe-button [videoChannel]="video.channel"></my-subscribe-button>
+            <my-subscribe-button [videoChannel]="video.channel" size="small"></my-subscribe-button>
           </div>
 
           <div class="video-info-by">
index 5bf2f485aa261d44590ec28f277cfb341cac8f93..6b18dc88a9df5c4bd7fde78889c1679f47664f24 100644 (file)
         }
 
         my-subscribe-button {
-          /deep/ span[role=button] {
-            font-size: 13px !important;
-          }
-
           margin-left: 5px;
         }
       }
index 272a3cb4676121af242300423eae5a25371a3a55..fc698ae96163115e1a1a4bacefe4be3e138161e9 100644 (file)
@@ -2,7 +2,7 @@ listen:
   hostname: 'localhost'
   port: 9000
 
-# Correspond to your reverse proxy "listen" configuration
+# Correspond to your reverse proxy server_name/listen configuration
 webserver:
   https: true
   hostname: 'example.com'
index f408e7932ee6451ddd9f303271fee8400b24eeb3..87aa5d76ff387836a08109c47a951a313846f06d 100644 (file)
@@ -1,22 +1,26 @@
 import * as express from 'express'
 import { buildNSFWFilter } from '../../helpers/express-utils'
-import { getFormattedObjects } from '../../helpers/utils'
+import { getFormattedObjects, getServerActor } from '../../helpers/utils'
 import { VideoModel } from '../../models/video/video'
 import {
   asyncMiddleware,
   commonVideosFiltersValidator,
   optionalAuthenticate,
   paginationValidator,
-  searchValidator,
   setDefaultPagination,
   setDefaultSearchSort,
-  videosSearchSortValidator
+  videoChannelsSearchSortValidator,
+  videoChannelsSearchValidator,
+  videosSearchSortValidator,
+  videosSearchValidator
 } from '../../middlewares'
-import { VideosSearchQuery } from '../../../shared/models/search'
-import { getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub'
+import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
+import { getOrCreateActorAndServerAndModel, getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub'
 import { logger } from '../../helpers/logger'
 import { User } from '../../../shared/models/users'
 import { CONFIG } from '../../initializers/constants'
+import { VideoChannelModel } from '../../models/video/video-channel'
+import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
 
 const searchRouter = express.Router()
 
@@ -27,21 +31,80 @@ searchRouter.get('/videos',
   setDefaultSearchSort,
   optionalAuthenticate,
   commonVideosFiltersValidator,
-  searchValidator,
+  videosSearchValidator,
   asyncMiddleware(searchVideos)
 )
 
+searchRouter.get('/video-channels',
+  paginationValidator,
+  setDefaultPagination,
+  videoChannelsSearchSortValidator,
+  setDefaultSearchSort,
+  optionalAuthenticate,
+  commonVideosFiltersValidator,
+  videoChannelsSearchValidator,
+  asyncMiddleware(searchVideoChannels)
+)
+
 // ---------------------------------------------------------------------------
 
 export { searchRouter }
 
 // ---------------------------------------------------------------------------
 
+function searchVideoChannels (req: express.Request, res: express.Response) {
+  const query: VideoChannelsSearchQuery = req.query
+  const search = query.search
+
+  const isURISearch = search.startsWith('http://') || search.startsWith('https://')
+
+  const parts = search.split('@')
+  const isHandleSearch = parts.length === 2 && parts.every(p => p.indexOf(' ') === -1)
+
+  if (isURISearch || isHandleSearch) return searchVideoChannelURI(search, isHandleSearch, res)
+
+  return searchVideoChannelsDB(query, res)
+}
+
+async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
+  const serverActor = await getServerActor()
+
+  const options = {
+    actorId: serverActor.id,
+    search: query.search,
+    start: query.start,
+    count: query.count,
+    sort: query.sort
+  }
+  const resultList = await VideoChannelModel.searchForApi(options)
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function searchVideoChannelURI (search: string, isHandleSearch: boolean, res: express.Response) {
+  let videoChannel: VideoChannelModel
+
+  if (isUserAbleToSearchRemoteURI(res)) {
+    let uri = search
+    if (isHandleSearch) uri = await loadActorUrlOrGetFromWebfinger(search)
+
+    const actor = await getOrCreateActorAndServerAndModel(uri)
+    videoChannel = actor.VideoChannel
+  } else {
+    videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(search)
+  }
+
+  return res.json({
+    total: videoChannel ? 1 : 0,
+    data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
+  })
+}
+
 function searchVideos (req: express.Request, res: express.Response) {
   const query: VideosSearchQuery = req.query
   const search = query.search
   if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
-    return searchVideoUrl(search, res)
+    return searchVideoURI(search, res)
   }
 
   return searchVideosDB(query, res)
@@ -57,15 +120,11 @@ async function searchVideosDB (query: VideosSearchQuery, res: express.Response)
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
 
-async function searchVideoUrl (url: string, res: express.Response) {
+async function searchVideoURI (url: string, res: express.Response) {
   let video: VideoModel
-  const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
   // Check if we can fetch a remote video with the URL
-  if (
-    CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
-    (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
-  ) {
+  if (isUserAbleToSearchRemoteURI(res)) {
     try {
       const syncParam = {
         likes: false,
@@ -76,8 +135,8 @@ async function searchVideoUrl (url: string, res: express.Response) {
         refreshVideo: false
       }
 
-      const res = await getOrCreateVideoAndAccountAndChannel(url, syncParam)
-      video = res ? res.video : undefined
+      const result = await getOrCreateVideoAndAccountAndChannel(url, syncParam)
+      video = result ? result.video : undefined
     } catch (err) {
       logger.info('Cannot search remote video %s.', url)
     }
@@ -90,3 +149,10 @@ async function searchVideoUrl (url: string, res: express.Response) {
     data: video ? [ video.toFormattedJSON() ] : []
   })
 }
+
+function isUserAbleToSearchRemoteURI (res: express.Response) {
+  const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined
+
+  return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
+    (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
+}
index 2300f5dbe301003290bf64d490d5bb3c83bb9e54..000c706b5c10a592a130df386e8e7a865d60e525 100644 (file)
@@ -20,7 +20,8 @@ import {
   deleteMeValidator,
   userSubscriptionsSortValidator,
   videoImportsSortValidator,
-  videosSortValidator
+  videosSortValidator,
+  areSubscriptionsExistValidator
 } from '../../../middlewares/validators'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
 import { UserModel } from '../../../models/account/user'
@@ -98,7 +99,6 @@ meRouter.post('/me/avatar/pick',
 // ##### Subscriptions part #####
 
 meRouter.get('/me/subscriptions/videos',
-  authenticate,
   authenticate,
   paginationValidator,
   videosSortValidator,
@@ -108,6 +108,12 @@ meRouter.get('/me/subscriptions/videos',
   asyncMiddleware(getUserSubscriptionVideos)
 )
 
+meRouter.get('/me/subscriptions/exist',
+  authenticate,
+  areSubscriptionsExistValidator,
+  asyncMiddleware(areSubscriptionsExist)
+)
+
 meRouter.get('/me/subscriptions',
   authenticate,
   paginationValidator,
@@ -143,6 +149,37 @@ export {
 
 // ---------------------------------------------------------------------------
 
+async function areSubscriptionsExist (req: express.Request, res: express.Response) {
+  const uris = req.query.uris as string[]
+  const user = res.locals.oauth.token.User as UserModel
+
+  const handles = uris.map(u => {
+    let [ name, host ] = u.split('@')
+    if (host === CONFIG.WEBSERVER.HOST) host = null
+
+    return { name, host, uri: u }
+  })
+
+  const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles)
+
+  const existObject: { [id: string ]: boolean } = {}
+  for (const handle of handles) {
+    const obj = results.find(r => {
+      const server = r.ActorFollowing.Server
+
+      return r.ActorFollowing.preferredUsername === handle.name &&
+        (
+          (!server && !handle.host) ||
+          (server.host === handle.host)
+        )
+    })
+
+    existObject[handle.uri] = obj !== undefined
+  }
+
+  return res.json(existObject)
+}
+
 async function addUserSubscription (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.User as UserModel
   const [ name, host ] = req.body.uri.split('@')
index 3f51f03f4fcb9816ffed70f8ba205a9501de9c41..bd08d7a0809db521d4c83325c8cf9427e3a3cefb 100644 (file)
@@ -1,5 +1,5 @@
 import * as express from 'express'
-import { getFormattedObjects } from '../../helpers/utils'
+import { getFormattedObjects, getServerActor } from '../../helpers/utils'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -95,7 +95,8 @@ export {
 // ---------------------------------------------------------------------------
 
 async function listVideoChannels (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const resultList = await VideoChannelModel.listForApi(req.query.start, req.query.count, req.query.sort)
+  const serverActor = await getServerActor()
+  const resultList = await VideoChannelModel.listForApi(serverActor.id, req.query.start, req.query.count, req.query.sort)
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
index c3a62c12d3389a0c9771bacaa22fb0469d2d17f0..6958b2b00568b6094f9b7dd042a54ea2bf14e1e7 100644 (file)
@@ -1,6 +1,6 @@
 import * as validator from 'validator'
 import { CONSTRAINTS_FIELDS } from '../../../initializers'
-import { exists } from '../misc'
+import { exists, isArray } from '../misc'
 import { truncate } from 'lodash'
 import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
 import { isHostValid } from '../servers'
@@ -119,10 +119,15 @@ function isValidActorHandle (handle: string) {
   return isHostValid(parts[1])
 }
 
+function areValidActorHandles (handles: string[]) {
+  return isArray(handles) && handles.every(h => isValidActorHandle(h))
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   normalizeActor,
+  areValidActorHandles,
   isActorEndpointsObjectValid,
   isActorPublicKeyObjectValid,
   isActorTypeValid,
index 46b63c5e903ae4579404914c03ae90d0eefd7cd0..9beb9b7c242741388d09085c12babff0f0646265 100644 (file)
@@ -43,7 +43,8 @@ const SORTABLE_COLUMNS = {
   FOLLOWERS: [ 'createdAt' ],
   FOLLOWING: [ 'createdAt' ],
 
-  VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ]
+  VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ],
+  VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName' ]
 }
 
 const OAUTH_LIFETIME = {
index 07a5ff92f1227936fb848e40d59c7461646b8d86..d2ad738a26f0414544d61e0b1d7c7396a6c4bce7 100644 (file)
@@ -7,7 +7,7 @@ import { AccountModel } from '../../../models/account/account'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoChannelModel } from '../../../models/video/video-channel'
 import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor'
-import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannel, updateVideoFromAP } from '../videos'
+import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
 import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
 
 async function processUpdateActivity (activity: ActivityUpdate) {
@@ -40,7 +40,7 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
   }
 
   const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id)
-  const channelActor = await getOrCreateVideoChannel(videoObject)
+  const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
 
   return updateVideoFromAP(video, videoObject, actor, channelActor, activity.to)
 }
index 388c31fe5d6d6dfba80a9e4ef6631e2fb8d0cf5b..6c2095897f07e155ff598def8e9f88e4db0107c7 100644 (file)
@@ -174,7 +174,7 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje
   return attributes
 }
 
-function getOrCreateVideoChannel (videoObject: VideoTorrentObject) {
+function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
   const channel = videoObject.attributedTo.find(a => a.type === 'Group')
   if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
 
@@ -251,7 +251,7 @@ async function getOrCreateVideoAndAccountAndChannel (
   const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
   if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
 
-  const channelActor = await getOrCreateVideoChannel(fetchedVideo)
+  const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
   const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
 
   // Process outside the transaction because we could fetch remote data
@@ -329,7 +329,7 @@ async function refreshVideoIfNeeded (video: VideoModel): Promise<VideoModel> {
       return video
     }
 
-    const channelActor = await getOrCreateVideoChannel(videoObject)
+    const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
     const account = await AccountModel.load(channelActor.VideoChannel.accountId)
     return updateVideoFromAP(video, videoObject, account.Actor, channelActor)
 
@@ -440,7 +440,7 @@ export {
   videoActivityObjectToDBAttributes,
   videoFileActivityUrlToDBAttributes,
   createVideo,
-  getOrCreateVideoChannel,
+  getOrCreateVideoChannelFromVideoObject,
   addVideoShares,
   createRates
 }
index faefc1179a17e7a3321ed0c70c2d35c254c195fa..73fa28be95556bf83a7e7efc50716c6eba497e37 100644 (file)
@@ -38,7 +38,7 @@ const removeFollowingValidator = [
     if (areValidationErrors(req, res)) return
 
     const serverActor = await getServerActor()
-    const follow = await ActorFollowModel.loadByActorAndTargetNameAndHost(serverActor.id, SERVER_ACTOR_NAME, req.params.host)
+    const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host)
 
     if (!follow) {
       return res
index e516c4c41d550befd2969ddfa5c3273752bc49e9..8baf643a50d042cd6d984e5e05b95c5212cde6be 100644 (file)
@@ -5,7 +5,7 @@ import { query } from 'express-validator/check'
 import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
 import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
 
-const searchValidator = [
+const videosSearchValidator = [
   query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
 
   query('startDate').optional().custom(isDateValid).withMessage('Should have a valid start date'),
@@ -15,7 +15,19 @@ const searchValidator = [
   query('durationMax').optional().isInt().withMessage('Should have a valid max duration'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking search query', { parameters: req.query })
+    logger.debug('Checking videos search query', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+const videoChannelsSearchValidator = [
+  query('search').not().isEmpty().withMessage('Should have a valid search'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking video channels search query', { parameters: req.query })
 
     if (areValidationErrors(req, res)) return
 
@@ -61,5 +73,6 @@ const commonVideosFiltersValidator = [
 
 export {
   commonVideosFiltersValidator,
-  searchValidator
+  videoChannelsSearchValidator,
+  videosSearchValidator
 }
index b30e97e619254166907fd220f1772a1fdb893c2c..08dcc268096fc8acbab28f28cecc8613f1d961a6 100644 (file)
@@ -8,6 +8,7 @@ const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
 const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
 const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
 const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
+const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
 const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
 const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
 const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
@@ -23,6 +24,7 @@ const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
 const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
 const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
 const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
+const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
 const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
 const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
 const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
@@ -45,5 +47,6 @@ export {
   followingSortValidator,
   jobsSortValidator,
   videoCommentThreadsSortValidator,
-  userSubscriptionsSortValidator
+  userSubscriptionsSortValidator,
+  videoChannelsSearchSortValidator
 }
index d8c26c742d6ee9fb5809892e88aa8eed061086f7..c5f8d9d4ce996b7fa9704a2181cfd9dc8650cea0 100644 (file)
@@ -1,12 +1,13 @@
 import * as express from 'express'
 import 'express-validator'
-import { body, param } from 'express-validator/check'
+import { body, param, query } from 'express-validator/check'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
 import { ActorFollowModel } from '../../models/activitypub/actor-follow'
-import { isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
+import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
 import { UserModel } from '../../models/account/user'
 import { CONFIG } from '../../initializers'
+import { toArray } from '../../helpers/custom-validators/misc'
 
 const userSubscriptionAddValidator = [
   body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'),
@@ -20,6 +21,20 @@ const userSubscriptionAddValidator = [
   }
 ]
 
+const areSubscriptionsExistValidator = [
+  query('uris')
+    .customSanitizer(toArray)
+    .custom(areValidActorHandles).withMessage('Should have a valid uri array'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking areSubscriptionsExistValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
 const userSubscriptionGetValidator = [
   param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'),
 
@@ -32,7 +47,7 @@ const userSubscriptionGetValidator = [
     if (host === CONFIG.WEBSERVER.HOST) host = null
 
     const user: UserModel = res.locals.oauth.token.User
-    const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHost(user.Account.Actor.id, name, host)
+    const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(user.Account.Actor.id, name, host)
 
     if (!subscription || !subscription.ActorFollowing.VideoChannel) {
       return res
@@ -51,8 +66,7 @@ const userSubscriptionGetValidator = [
 // ---------------------------------------------------------------------------
 
 export {
+  areSubscriptionsExistValidator,
   userSubscriptionAddValidator,
   userSubscriptionGetValidator
 }
-
-// ---------------------------------------------------------------------------
index 07539a04eb561e640291dfa971ee862a8ecc8eed..6bbfc6f4e8d50f223c8eddb45f2f22ad50de9178 100644 (file)
@@ -29,18 +29,8 @@ import { UserModel } from './user'
 @DefaultScope({
   include: [
     {
-      model: () => ActorModel,
-      required: true,
-      include: [
-        {
-          model: () => ServerModel,
-          required: false
-        },
-        {
-          model: () => AvatarModel,
-          required: false
-        }
-      ]
+      model: () => ActorModel, // Default scope includes avatar and server
+      required: true
     }
   ]
 })
index b2d7ace6698934fac893302c18742b61c014825b..81fcf700187fd86160116d0dc30548849b10a027 100644 (file)
@@ -26,7 +26,7 @@ import { ACTOR_FOLLOW_SCORE } from '../../initializers'
 import { FOLLOW_STATES } from '../../initializers/constants'
 import { ServerModel } from '../server/server'
 import { getSort } from '../utils'
-import { ActorModel } from './actor'
+import { ActorModel, unusedActorAttributesForAPI } from './actor'
 import { VideoChannelModel } from '../video/video-channel'
 import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions'
 import { AccountModel } from '../account/account'
@@ -167,8 +167,11 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
     return ActorFollowModel.findOne(query)
   }
 
-  static loadByActorAndTargetNameAndHost (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) {
+  static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) {
     const actorFollowingPartInclude: IIncludeOptions = {
+      attributes: {
+        exclude: unusedActorAttributesForAPI
+      },
       model: ActorModel,
       required: true,
       as: 'ActorFollowing',
@@ -177,7 +180,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
       },
       include: [
         {
-          model: VideoChannelModel,
+          model: VideoChannelModel.unscoped(),
           required: false
         }
       ]
@@ -200,17 +203,79 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
         actorId
       },
       include: [
-        {
-          model: ActorModel,
-          required: true,
-          as: 'ActorFollower'
-        },
         actorFollowingPartInclude
       ],
       transaction: t
     }
 
     return ActorFollowModel.findOne(query)
+      .then(result => {
+        if (result && result.ActorFollowing.VideoChannel) {
+          result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing
+        }
+
+        return result
+      })
+  }
+
+  static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]) {
+    const whereTab = targets
+      .map(t => {
+        if (t.host) {
+          return {
+            [ Sequelize.Op.and ]: [
+              {
+                '$preferredUsername$': t.name
+              },
+              {
+                '$host$': t.host
+              }
+            ]
+          }
+        }
+
+        return {
+          [ Sequelize.Op.and ]: [
+            {
+              '$preferredUsername$': t.name
+            },
+            {
+              '$serverId$': null
+            }
+          ]
+        }
+      })
+
+    const query = {
+      attributes: [],
+      where: {
+        [ Sequelize.Op.and ]: [
+          {
+            [ Sequelize.Op.or ]: whereTab
+          },
+          {
+            actorId
+          }
+        ]
+      },
+      include: [
+        {
+          attributes: [ 'preferredUsername' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          as: 'ActorFollowing',
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            }
+          ]
+        }
+      ]
+    }
+
+    return ActorFollowModel.findAll(query)
   }
 
   static listFollowingForApi (id: number, start: number, count: number, sort: string) {
@@ -248,6 +313,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
 
   static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
     const query = {
+      attributes: [],
       distinct: true,
       offset: start,
       limit: count,
@@ -257,6 +323,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
       },
       include: [
         {
+          attributes: {
+            exclude: unusedActorAttributesForAPI
+          },
           model: ActorModel,
           as: 'ActorFollowing',
           required: true,
@@ -266,8 +335,24 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
               required: true,
               include: [
                 {
-                  model: AccountModel,
+                  attributes: {
+                    exclude: unusedActorAttributesForAPI
+                  },
+                  model: ActorModel,
                   required: true
+                },
+                {
+                  model: AccountModel,
+                  required: true,
+                  include: [
+                    {
+                      attributes: {
+                        exclude: unusedActorAttributesForAPI
+                      },
+                      model: ActorModel,
+                      required: true
+                    }
+                  ]
                 }
               ]
             }
index 2abf4071373ff4504b993fc214a181018f3384c6..ec0b4b2d93ac3d944a6c34ab3e43131f9750a93f 100644 (file)
@@ -42,6 +42,16 @@ enum ScopeNames {
   FULL = 'FULL'
 }
 
+export const unusedActorAttributesForAPI = [
+  'publicKey',
+  'privateKey',
+  'inboxUrl',
+  'outboxUrl',
+  'sharedInboxUrl',
+  'followersUrl',
+  'followingUrl'
+]
+
 @DefaultScope({
   include: [
     {
index 9f80e0b8daab4b8e6629736a7b307f159bf48e37..7d717fc68fd21f1b6bff6abbb8242cb0a5e05ebb 100644 (file)
@@ -12,6 +12,7 @@ import {
   Is,
   Model,
   Scopes,
+  Sequelize,
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
@@ -24,19 +25,36 @@ import {
 } from '../../helpers/custom-validators/video-channels'
 import { sendDeleteActor } from '../../lib/activitypub/send'
 import { AccountModel } from '../account/account'
-import { ActorModel } from '../activitypub/actor'
-import { getSort, throwIfNotValid } from '../utils'
+import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
+import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
 import { VideoModel } from './video'
 import { CONSTRAINTS_FIELDS } from '../../initializers'
-import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
+import { DefineIndexesOptions } from 'sequelize'
+
+// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
+const indexes: DefineIndexesOptions[] = [
+  buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
+
+  {
+    fields: [ 'accountId' ]
+  },
+  {
+    fields: [ 'actorId' ]
+  }
+]
 
 enum ScopeNames {
+  AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
   WITH_ACCOUNT = 'WITH_ACCOUNT',
   WITH_ACTOR = 'WITH_ACTOR',
   WITH_VIDEOS = 'WITH_VIDEOS'
 }
 
+type AvailableForListOptions = {
+  actorId: number
+}
+
 @DefaultScope({
   include: [
     {
@@ -46,23 +64,57 @@ enum ScopeNames {
   ]
 })
 @Scopes({
-  [ScopeNames.WITH_ACCOUNT]: {
-    include: [
-      {
-        model: () => AccountModel.unscoped(),
-        required: true,
-        include: [
-          {
-            model: () => ActorModel.unscoped(),
-            required: true,
-            include: [
+  [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
+    const actorIdNumber = parseInt(options.actorId + '', 10)
+
+    // Only list local channels OR channels that are on an instance followed by actorId
+    const inQueryInstanceFollow = '(' +
+      'SELECT "actor"."serverId" FROM "actor" ' +
+      'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = actor.id ' +
+      'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+    ')'
+
+    return {
+      include: [
+        {
+          attributes: {
+            exclude: unusedActorAttributesForAPI
+          },
+          model: ActorModel,
+          where: {
+            [Sequelize.Op.or]: [
+              {
+                serverId: null
+              },
               {
-                model: () => AvatarModel.unscoped(),
-                required: false
+                serverId: {
+                  [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
+                }
               }
             ]
           }
-        ]
+        },
+        {
+          model: AccountModel,
+          required: true,
+          include: [
+            {
+              attributes: {
+                exclude: unusedActorAttributesForAPI
+              },
+              model: ActorModel, // Default scope includes avatar and server
+              required: true
+            }
+          ]
+        }
+      ]
+    }
+  },
+  [ScopeNames.WITH_ACCOUNT]: {
+    include: [
+      {
+        model: () => AccountModel,
+        required: true
       }
     ]
   },
@@ -79,14 +131,7 @@ enum ScopeNames {
 })
 @Table({
   tableName: 'videoChannel',
-  indexes: [
-    {
-      fields: [ 'accountId' ]
-    },
-    {
-      fields: [ 'actorId' ]
-    }
-  ]
+  indexes
 })
 export class VideoChannelModel extends Model<VideoChannelModel> {
 
@@ -170,15 +215,61 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
     return VideoChannelModel.count(query)
   }
 
-  static listForApi (start: number, count: number, sort: string) {
+  static listForApi (actorId: number, start: number, count: number, sort: string) {
     const query = {
       offset: start,
       limit: count,
       order: getSort(sort)
     }
 
+    const scopes = {
+      method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ]
+    }
     return VideoChannelModel
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .scope(scopes)
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
+  }
+
+  static searchForApi (options: {
+    actorId: number
+    search: string
+    start: number
+    count: number
+    sort: string
+  }) {
+    const attributesInclude = []
+    const escapedSearch = VideoModel.sequelize.escape(options.search)
+    const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
+    attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
+
+    const query = {
+      attributes: {
+        include: attributesInclude
+      },
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort),
+      where: {
+        id: {
+          [ Sequelize.Op.in ]: Sequelize.literal(
+            '(' +
+              'SELECT id FROM "videoChannel" WHERE ' +
+              'lower(immutable_unaccent("name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
+              'lower(immutable_unaccent("name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
+            ')'
+          )
+        }
+      }
+    }
+
+    const scopes = {
+      method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: options.actorId } as AvailableForListOptions ]
+    }
+    return VideoChannelModel
+      .scope(scopes)
       .findAndCountAll(query)
       .then(({ rows, count }) => {
         return { total: count, data: rows }
@@ -239,7 +330,25 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
     }
 
     return VideoChannelModel
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .scope([ ScopeNames.WITH_ACCOUNT ])
+      .findOne(query)
+  }
+
+  static loadByUrlAndPopulateAccount (url: string) {
+    const query = {
+      include: [
+        {
+          model: ActorModel,
+          required: true,
+          where: {
+            url
+          }
+        }
+      ]
+    }
+
+    return VideoChannelModel
+      .scope([ ScopeNames.WITH_ACCOUNT ])
       .findOne(query)
   }
 
index 6a6dd9a6f976f476a4ecbbc5cb575dca847fabac..9fba99ac8f92d7e2283396c39e776f9bef989432 100644 (file)
@@ -202,6 +202,46 @@ describe('Test user subscriptions API validators', function () {
     })
   })
 
+  describe('When checking if subscriptions exist', async function () {
+    const existPath = path + '/exist'
+
+    it('Should fail with a non authenticated user', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path: existPath,
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should fail with bad URIs', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path: existPath,
+        query: { uris: 'toto' },
+        token: server.accessToken,
+        statusCodeExpected: 400
+      })
+
+      await makeGetRequest({
+        url: server.url,
+        path: existPath,
+        query: { 'uris[]': 1 },
+        token: server.accessToken,
+        statusCodeExpected: 400
+      })
+    })
+
+    it('Should succeed with the correct parameters', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path: existPath,
+        query: { 'uris[]': 'coucou@localhost:9001' },
+        token: server.accessToken,
+        statusCodeExpected: 200
+      })
+    })
+  })
+
   describe('When removing a subscription', function () {
     it('Should fail with a non authenticated user', async function () {
       await makeDeleteRequest({
index cb7d94b0b9aff80a983cc93f83626c672945802e..65b80540c858210367c2e93386a3ea18c6a67bd8 100644 (file)
@@ -12,7 +12,7 @@ import {
   listUserSubscriptions,
   listUserSubscriptionVideos,
   removeUserSubscription,
-  getUserSubscription
+  getUserSubscription, areSubscriptionsExist
 } from '../../utils/users/user-subscriptions'
 
 const expect = chai.expect
@@ -128,6 +128,23 @@ describe('Test users subscriptions', function () {
     }
   })
 
+  it('Should return the existing subscriptions', async function () {
+    const uris = [
+      'user3_channel@localhost:9003',
+      'root2_channel@localhost:9001',
+      'root_channel@localhost:9001',
+      'user3_channel@localhost:9001'
+    ]
+
+    const res = await areSubscriptionsExist(servers[ 0 ].url, users[ 0 ].accessToken, uris)
+    const body = res.body
+
+    expect(body['user3_channel@localhost:9003']).to.be.true
+    expect(body['root2_channel@localhost:9001']).to.be.false
+    expect(body['root_channel@localhost:9001']).to.be.true
+    expect(body['user3_channel@localhost:9001']).to.be.false
+  })
+
   it('Should list subscription videos', async function () {
     {
       const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken)
index 852f590cfaeb486caa150e56b306ed64c8a90fb2..b0e7da7cc17dbc7302390a2dce8e4c34ee5d7a1d 100644 (file)
@@ -58,9 +58,22 @@ function removeUserSubscription (url: string, token: string, uri: string, status
   })
 }
 
+function areSubscriptionsExist (url: string, token: string, uris: string[], statusCodeExpected = 200) {
+  const path = '/api/v1/users/me/subscriptions/exist'
+
+  return makeGetRequest({
+    url,
+    path,
+    query: { 'uris[]': uris },
+    token,
+    statusCodeExpected
+  })
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  areSubscriptionsExist,
   addUserSubscription,
   listUserSubscriptions,
   getUserSubscription,
index 928846c39cf82b0d67b7813e8d314e9c3a285d82..28dd954437825e0b131f14a67ff160ce1753be56 100644 (file)
@@ -1,2 +1,3 @@
 export * from './nsfw-query.model'
 export * from './videos-search-query.model'
+export * from './video-channels-search-query.model'
diff --git a/shared/models/search/video-channels-search-query.model.ts b/shared/models/search/video-channels-search-query.model.ts
new file mode 100644 (file)
index 0000000..de2741e
--- /dev/null
@@ -0,0 +1,7 @@
+export interface VideoChannelsSearchQuery {
+  search: string
+
+  start?: number
+  count?: number
+  sort?: string
+}