diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index e54a8b32a..be7dd39cf 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -103,6 +103,42 @@
}
}
}
+
+ &.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;
+ }
+ }
+ }
+ }
}
}
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index 8d615fd05..f88df6391 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -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 () {
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index a37c49161..cd3bdad35 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -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
>(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>(url, { params })
+ .pipe(
+ map(res => VideoChannelService.extractVideoChannels(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
}
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts
index 5d5410de9..4560c2024 100644
--- a/client/src/app/shared/rest/rest.service.ts
+++ b/client/src/app/shared/rest/rest.service.ts
@@ -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
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html
index 63b313662..34c024c17 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.html
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.html
@@ -1,11 +1,11 @@
-
+
Subscribe
{{ videoChannel.followersCount | myNumberFormatter }}
-
+
Subscribed
Unsubscribe
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss
index 9811fdc0c..b78d2f59c 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.scss
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.scss
@@ -13,7 +13,21 @@
.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 {
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
index 46d6dbaf7..ba7acf69a 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.ts
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts
@@ -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)
)
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts
index 3103706d1..cf622019f 100644
--- a/client/src/app/shared/user-subscription/user-subscription.service.ts
+++ b/client/src/app/shared/user-subscription/user-subscription.service.ts
@@ -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 = new ReplaySubject(1)
+ private existsObservable: Observable
+
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 {
- 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 {
+ 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(url, { params })
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
}
}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index 8a49e3566..e9c79741e 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -43,7 +43,7 @@
-
+
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index 5bf2f485a..6b18dc88a 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -127,10 +127,6 @@
}
my-subscribe-button {
- /deep/ span[role=button] {
- font-size: 13px !important;
- }
-
margin-left: 5px;
}
}
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 272a3cb46..fc698ae96 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -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'
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
index f408e7932..87aa5d76f 100644
--- a/server/controllers/api/search.ts
+++ b/server/controllers/api/search.ts
@@ -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)
+}
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 2300f5dbe..000c706b5 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -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('@')
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index 3f51f03f4..bd08d7a08 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -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))
}
diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts
index c3a62c12d..6958b2b00 100644
--- a/server/helpers/custom-validators/activitypub/actor.ts
+++ b/server/helpers/custom-validators/activitypub/actor.ts
@@ -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,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 46b63c5e9..9beb9b7c2 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -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 = {
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index 07a5ff92f..d2ad738a2 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -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)
}
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 388c31fe5..6c2095897 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -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
{
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
}
diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts
index faefc1179..73fa28be9 100644
--- a/server/middlewares/validators/follows.ts
+++ b/server/middlewares/validators/follows.ts
@@ -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
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index e516c4c41..8baf643a5 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -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
}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index b30e97e61..08dcc2680 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -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
}
diff --git a/server/middlewares/validators/user-subscriptions.ts b/server/middlewares/validators/user-subscriptions.ts
index d8c26c742..c5f8d9d4c 100644
--- a/server/middlewares/validators/user-subscriptions.ts
+++ b/server/middlewares/validators/user-subscriptions.ts
@@ -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
}
-
-// ---------------------------------------------------------------------------
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 07539a04e..6bbfc6f4e 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -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
}
]
})
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts
index b2d7ace66..81fcf7001 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/activitypub/actor-follow.ts
@@ -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 {
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 {
},
include: [
{
- model: VideoChannelModel,
+ model: VideoChannelModel.unscoped(),
required: false
}
]
@@ -200,17 +203,79 @@ export class ActorFollowModel extends Model {
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 {
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 {
},
include: [
{
+ attributes: {
+ exclude: unusedActorAttributesForAPI
+ },
model: ActorModel,
as: 'ActorFollowing',
required: true,
@@ -266,8 +335,24 @@ export class ActorFollowModel extends Model {
required: true,
include: [
{
- model: AccountModel,
+ attributes: {
+ exclude: unusedActorAttributesForAPI
+ },
+ model: ActorModel,
required: true
+ },
+ {
+ model: AccountModel,
+ required: true,
+ include: [
+ {
+ attributes: {
+ exclude: unusedActorAttributesForAPI
+ },
+ model: ActorModel,
+ required: true
+ }
+ ]
}
]
}
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 2abf40713..ec0b4b2d9 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -42,6 +42,16 @@ enum ScopeNames {
FULL = 'FULL'
}
+export const unusedActorAttributesForAPI = [
+ 'publicKey',
+ 'privateKey',
+ 'inboxUrl',
+ 'outboxUrl',
+ 'sharedInboxUrl',
+ 'followersUrl',
+ 'followingUrl'
+]
+
@DefaultScope({
include: [
{
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 9f80e0b8d..7d717fc68 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -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]: [
{
- model: () => AvatarModel.unscoped(),
- required: false
+ serverId: null
+ },
+ {
+ 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 {
@@ -170,15 +215,61 @@ export class VideoChannelModel extends Model {
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 {
}
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)
}
diff --git a/server/tests/api/check-params/user-subscriptions.ts b/server/tests/api/check-params/user-subscriptions.ts
index 6a6dd9a6f..9fba99ac8 100644
--- a/server/tests/api/check-params/user-subscriptions.ts
+++ b/server/tests/api/check-params/user-subscriptions.ts
@@ -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({
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index cb7d94b0b..65b80540c 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -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)
diff --git a/server/tests/utils/users/user-subscriptions.ts b/server/tests/utils/users/user-subscriptions.ts
index 852f590cf..b0e7da7cc 100644
--- a/server/tests/utils/users/user-subscriptions.ts
+++ b/server/tests/utils/users/user-subscriptions.ts
@@ -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,
diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts
index 928846c39..28dd95443 100644
--- a/shared/models/search/index.ts
+++ b/shared/models/search/index.ts
@@ -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
index 000000000..de2741e14
--- /dev/null
+++ b/shared/models/search/video-channels-search-query.model.ts
@@ -0,0 +1,7 @@
+export interface VideoChannelsSearchQuery {
+ search: string
+
+ start?: number
+ count?: number
+ sort?: string
+}