1
0
Fork 0

Add ability to search video channels

This commit is contained in:
Chocobozzz 2018-08-23 17:58:39 +02:00
parent 240085d005
commit f37dc0dd14
35 changed files with 670 additions and 145 deletions

View file

@ -37,13 +37,6 @@
.actor-owner { .actor-owner {
@include actor-owner; @include actor-owner;
} }
my-subscribe-button {
/deep/ span[role=button] {
padding: 7px 12px;
font-size: 16px;
}
}
} }

View file

@ -21,7 +21,7 @@ export class MyAccountSubscriptionsComponent implements OnInit {
ngOnInit () { ngOnInit () {
this.userSubscriptionService.listSubscriptions() this.userSubscriptionService.listSubscriptions()
.subscribe( .subscribe(
res => { console.log(res); this.videoChannels = res.data }, res => this.videoChannels = res.data,
error => this.notificationsService.error(this.i18n('Error'), error.message) error => this.notificationsService.error(this.i18n('Error'), error.message)
) )

View file

@ -41,6 +41,10 @@
color: $grey-actor-name; color: $grey-actor-name;
margin-left: 5px; margin-left: 5px;
} }
.video-channel-followers {
}
} }
} }

View file

@ -22,10 +22,27 @@
</div> </div>
</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 No results found
</div> </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"> <div *ngFor="let video of videos" class="entry video">
<my-video-thumbnail [video]="video"></my-video-thumbnail> <my-video-thumbnail [video]="video"></my-video-thumbnail>

View file

@ -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;
}
}
}
}
} }
} }

View file

@ -2,13 +2,15 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { RedirectService } from '@app/core' import { RedirectService } from '@app/core'
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
import { Subscription } from 'rxjs' import { forkJoin, Subscription } from 'rxjs'
import { SearchService } from '@app/search/search.service' import { SearchService } from '@app/search/search.service'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model' import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { Video } from '../../../../shared' import { Video } from '../../../../shared'
import { MetaService } from '@ngx-meta/core' import { MetaService } from '@ngx-meta/core'
import { AdvancedSearch } from '@app/search/advanced-search.model' 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({ @Component({
selector: 'my-search', selector: 'my-search',
@ -17,18 +19,22 @@ import { AdvancedSearch } from '@app/search/advanced-search.model'
}) })
export class SearchComponent implements OnInit, OnDestroy { export class SearchComponent implements OnInit, OnDestroy {
videos: Video[] = [] videos: Video[] = []
videoChannels: VideoChannel[] = []
pagination: ComponentPagination = { pagination: ComponentPagination = {
currentPage: 1, 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 totalItems: null
} }
advancedSearch: AdvancedSearch = new AdvancedSearch() advancedSearch: AdvancedSearch = new AdvancedSearch()
isSearchFilterCollapsed = true isSearchFilterCollapsed = true
currentSearch: string
private subActivatedRoute: Subscription private subActivatedRoute: Subscription
private currentSearch: string
private isInitialLoad = true private isInitialLoad = true
private channelsPerPage = 2
constructor ( constructor (
private i18n: I18n, private i18n: I18n,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -74,17 +80,23 @@ export class SearchComponent implements OnInit, OnDestroy {
} }
search () { 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( .subscribe(
({ videos, totalVideos }) => { ([ videosResult, videoChannelsResult ]) => {
this.videos = this.videos.concat(videos) this.videos = this.videos.concat(videosResult.videos)
this.pagination.totalItems = totalVideos this.pagination.totalItems = videosResult.totalVideos
this.videoChannels = videoChannelsResult.data
}, },
error => { error => {
this.notificationsService.error(this.i18n('Error'), error.message) this.notificationsService.error(this.i18n('Error'), error.message)
} }
) )
} }
onNearOfBottom () { onNearOfBottom () {

View 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 { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Observable } from 'rxjs' 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 { VideoService } from '@app/shared/video/video.service'
import { RestExtractor, RestService } from '@app/shared' import { RestExtractor, RestService } from '@app/shared'
import { environment } from 'environments/environment' import { environment } from 'environments/environment'
import { ResultList, Video } from '../../../../shared' import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
import { Video as VideoServerModel } from '@app/shared/video/video.model' import { Video } from '@app/shared/video/video.model'
import { AdvancedSearch } from '@app/search/advanced-search.model' import { AdvancedSearch } from '@app/search/advanced-search.model'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
export type SearchResult = { import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
videosResult: { totalVideos: number, videos: Video[] }
}
@Injectable() @Injectable()
export class SearchService { export class SearchService {
@ -40,17 +38,7 @@ export class SearchService {
if (search) params = params.append('search', search) if (search) params = params.append('search', search)
const advancedSearchObject = advancedSearch.toAPIObject() const advancedSearchObject = advancedSearch.toAPIObject()
params = this.restService.addObjectParams(params, advancedSearchObject)
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)
}
}
return this.authHttp return this.authHttp
.get<ResultList<VideoServerModel>>(url, { params }) .get<ResultList<VideoServerModel>>(url, { params })
@ -59,4 +47,24 @@ export class SearchService {
catchError(err => this.restExtractor.handleError(err)) 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))
)
}
} }

View file

@ -32,6 +32,21 @@ export class RestService {
return newParams 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 { componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination {
const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
const count: number = componentPagination.itemsPerPage const count: number = componentPagination.itemsPerPage

View 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>Subscribe</span>
<span *ngIf="displayFollowers && videoChannel.followersCount !== 0" class="followers-count"> <span *ngIf="displayFollowers && videoChannel.followersCount !== 0" class="followers-count">
{{ videoChannel.followersCount | myNumberFormatter }} {{ videoChannel.followersCount | myNumberFormatter }}
</span> </span>
</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="subscribed" i18n>Subscribed</span>
<span class="unsubscribe" i18n>Unsubscribe</span> <span class="unsubscribe" i18n>Unsubscribe</span>

View file

@ -13,7 +13,21 @@
.subscribe-button, .subscribe-button,
.unsubscribe-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 { .unsubscribe-button {

View file

@ -15,6 +15,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
export class SubscribeButtonComponent implements OnInit { export class SubscribeButtonComponent implements OnInit {
@Input() videoChannel: VideoChannel @Input() videoChannel: VideoChannel
@Input() displayFollowers = false @Input() displayFollowers = false
@Input() size: 'small' | 'normal' = 'normal'
subscribed: boolean subscribed: boolean
@ -34,7 +35,7 @@ export class SubscribeButtonComponent implements OnInit {
ngOnInit () { ngOnInit () {
this.userSubscriptionService.isSubscriptionExists(this.uri) this.userSubscriptionService.isSubscriptionExists(this.uri)
.subscribe( .subscribe(
exists => this.subscribed = exists, res => this.subscribed = res[this.uri],
err => this.notificationsService.error(this.i18n('Error'), err.message) err => this.notificationsService.error(this.i18n('Error'), err.message)
) )

View file

@ -1,22 +1,36 @@
import { catchError, map } from 'rxjs/operators' import { bufferTime, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ResultList } from '../../../../../shared' import { ResultList } from '../../../../../shared'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest' import { RestExtractor, RestService } from '../rest'
import { Observable, of } from 'rxjs' import { Observable, ReplaySubject, Subject } from 'rxjs'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos' import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos'
type SubscriptionExistResult = { [ uri: string ]: boolean }
@Injectable() @Injectable()
export class UserSubscriptionService { export class UserSubscriptionService {
static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' 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 ( constructor (
private authHttp: HttpClient, 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) { deleteSubscription (nameWithHost: string) {
@ -50,17 +64,20 @@ export class UserSubscriptionService {
) )
} }
isSubscriptionExists (nameWithHost: string): Observable<boolean> { isSubscriptionExists (nameWithHost: string) {
const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost this.existsSubject.next(nameWithHost)
return this.authHttp.get(url) return this.existsObservable
.pipe( }
map(this.restExtractor.extractDataBool),
catchError(err => {
if (err.status === 404) return of(false)
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)))
} }
} }

View file

@ -43,7 +43,7 @@
<img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" />
</a> </a>
<my-subscribe-button [videoChannel]="video.channel"></my-subscribe-button> <my-subscribe-button [videoChannel]="video.channel" size="small"></my-subscribe-button>
</div> </div>
<div class="video-info-by"> <div class="video-info-by">

View file

@ -127,10 +127,6 @@
} }
my-subscribe-button { my-subscribe-button {
/deep/ span[role=button] {
font-size: 13px !important;
}
margin-left: 5px; margin-left: 5px;
} }
} }

View file

@ -2,7 +2,7 @@ listen:
hostname: 'localhost' hostname: 'localhost'
port: 9000 port: 9000
# Correspond to your reverse proxy "listen" configuration # Correspond to your reverse proxy server_name/listen configuration
webserver: webserver:
https: true https: true
hostname: 'example.com' hostname: 'example.com'

View file

@ -1,22 +1,26 @@
import * as express from 'express' import * as express from 'express'
import { buildNSFWFilter } from '../../helpers/express-utils' import { buildNSFWFilter } from '../../helpers/express-utils'
import { getFormattedObjects } from '../../helpers/utils' import { getFormattedObjects, getServerActor } from '../../helpers/utils'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../models/video/video'
import { import {
asyncMiddleware, asyncMiddleware,
commonVideosFiltersValidator, commonVideosFiltersValidator,
optionalAuthenticate, optionalAuthenticate,
paginationValidator, paginationValidator,
searchValidator,
setDefaultPagination, setDefaultPagination,
setDefaultSearchSort, setDefaultSearchSort,
videosSearchSortValidator videoChannelsSearchSortValidator,
videoChannelsSearchValidator,
videosSearchSortValidator,
videosSearchValidator
} from '../../middlewares' } from '../../middlewares'
import { VideosSearchQuery } from '../../../shared/models/search' import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
import { getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub' import { getOrCreateActorAndServerAndModel, getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { User } from '../../../shared/models/users' import { User } from '../../../shared/models/users'
import { CONFIG } from '../../initializers/constants' import { CONFIG } from '../../initializers/constants'
import { VideoChannelModel } from '../../models/video/video-channel'
import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
const searchRouter = express.Router() const searchRouter = express.Router()
@ -27,21 +31,80 @@ searchRouter.get('/videos',
setDefaultSearchSort, setDefaultSearchSort,
optionalAuthenticate, optionalAuthenticate,
commonVideosFiltersValidator, commonVideosFiltersValidator,
searchValidator, videosSearchValidator,
asyncMiddleware(searchVideos) asyncMiddleware(searchVideos)
) )
searchRouter.get('/video-channels',
paginationValidator,
setDefaultPagination,
videoChannelsSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
commonVideosFiltersValidator,
videoChannelsSearchValidator,
asyncMiddleware(searchVideoChannels)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { searchRouter } 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) { function searchVideos (req: express.Request, res: express.Response) {
const query: VideosSearchQuery = req.query const query: VideosSearchQuery = req.query
const search = query.search const search = query.search
if (search && (search.startsWith('http://') || search.startsWith('https://'))) { if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
return searchVideoUrl(search, res) return searchVideoURI(search, res)
} }
return searchVideosDB(query, 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)) 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 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 // Check if we can fetch a remote video with the URL
if ( if (isUserAbleToSearchRemoteURI(res)) {
CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
(CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
) {
try { try {
const syncParam = { const syncParam = {
likes: false, likes: false,
@ -76,8 +135,8 @@ async function searchVideoUrl (url: string, res: express.Response) {
refreshVideo: false refreshVideo: false
} }
const res = await getOrCreateVideoAndAccountAndChannel(url, syncParam) const result = await getOrCreateVideoAndAccountAndChannel(url, syncParam)
video = res ? res.video : undefined video = result ? result.video : undefined
} catch (err) { } catch (err) {
logger.info('Cannot search remote video %s.', url) logger.info('Cannot search remote video %s.', url)
} }
@ -90,3 +149,10 @@ async function searchVideoUrl (url: string, res: express.Response) {
data: video ? [ video.toFormattedJSON() ] : [] 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)
}

View file

@ -20,7 +20,8 @@ import {
deleteMeValidator, deleteMeValidator,
userSubscriptionsSortValidator, userSubscriptionsSortValidator,
videoImportsSortValidator, videoImportsSortValidator,
videosSortValidator videosSortValidator,
areSubscriptionsExistValidator
} from '../../../middlewares/validators' } from '../../../middlewares/validators'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { UserModel } from '../../../models/account/user' import { UserModel } from '../../../models/account/user'
@ -98,7 +99,6 @@ meRouter.post('/me/avatar/pick',
// ##### Subscriptions part ##### // ##### Subscriptions part #####
meRouter.get('/me/subscriptions/videos', meRouter.get('/me/subscriptions/videos',
authenticate,
authenticate, authenticate,
paginationValidator, paginationValidator,
videosSortValidator, videosSortValidator,
@ -108,6 +108,12 @@ meRouter.get('/me/subscriptions/videos',
asyncMiddleware(getUserSubscriptionVideos) asyncMiddleware(getUserSubscriptionVideos)
) )
meRouter.get('/me/subscriptions/exist',
authenticate,
areSubscriptionsExistValidator,
asyncMiddleware(areSubscriptionsExist)
)
meRouter.get('/me/subscriptions', meRouter.get('/me/subscriptions',
authenticate, authenticate,
paginationValidator, 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) { async function addUserSubscription (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User as UserModel const user = res.locals.oauth.token.User as UserModel
const [ name, host ] = req.body.uri.split('@') const [ name, host ] = req.body.uri.split('@')

View file

@ -1,5 +1,5 @@
import * as express from 'express' import * as express from 'express'
import { getFormattedObjects } from '../../helpers/utils' import { getFormattedObjects, getServerActor } from '../../helpers/utils'
import { import {
asyncMiddleware, asyncMiddleware,
asyncRetryTransactionMiddleware, asyncRetryTransactionMiddleware,
@ -95,7 +95,8 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function listVideoChannels (req: express.Request, res: express.Response, next: express.NextFunction) { 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)) return res.json(getFormattedObjects(resultList.data, resultList.total))
} }

View file

@ -1,6 +1,6 @@
import * as validator from 'validator' import * as validator from 'validator'
import { CONSTRAINTS_FIELDS } from '../../../initializers' import { CONSTRAINTS_FIELDS } from '../../../initializers'
import { exists } from '../misc' import { exists, isArray } from '../misc'
import { truncate } from 'lodash' import { truncate } from 'lodash'
import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
import { isHostValid } from '../servers' import { isHostValid } from '../servers'
@ -119,10 +119,15 @@ function isValidActorHandle (handle: string) {
return isHostValid(parts[1]) return isHostValid(parts[1])
} }
function areValidActorHandles (handles: string[]) {
return isArray(handles) && handles.every(h => isValidActorHandle(h))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
normalizeActor, normalizeActor,
areValidActorHandles,
isActorEndpointsObjectValid, isActorEndpointsObjectValid,
isActorPublicKeyObjectValid, isActorPublicKeyObjectValid,
isActorTypeValid, isActorTypeValid,

View file

@ -43,7 +43,8 @@ const SORTABLE_COLUMNS = {
FOLLOWERS: [ 'createdAt' ], FOLLOWERS: [ 'createdAt' ],
FOLLOWING: [ '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 = { const OAUTH_LIFETIME = {

View file

@ -7,7 +7,7 @@ import { AccountModel } from '../../../models/account/account'
import { ActorModel } from '../../../models/activitypub/actor' import { ActorModel } from '../../../models/activitypub/actor'
import { VideoChannelModel } from '../../../models/video/video-channel' import { VideoChannelModel } from '../../../models/video/video-channel'
import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' 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' import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
async function processUpdateActivity (activity: ActivityUpdate) { async function processUpdateActivity (activity: ActivityUpdate) {
@ -40,7 +40,7 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
} }
const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id) const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id)
const channelActor = await getOrCreateVideoChannel(videoObject) const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
return updateVideoFromAP(video, videoObject, actor, channelActor, activity.to) return updateVideoFromAP(video, videoObject, actor, channelActor, activity.to)
} }

View file

@ -174,7 +174,7 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje
return attributes return attributes
} }
function getOrCreateVideoChannel (videoObject: VideoTorrentObject) { function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
const channel = videoObject.attributedTo.find(a => a.type === 'Group') const channel = videoObject.attributedTo.find(a => a.type === 'Group')
if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) 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) const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + 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) const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
// Process outside the transaction because we could fetch remote data // Process outside the transaction because we could fetch remote data
@ -329,7 +329,7 @@ async function refreshVideoIfNeeded (video: VideoModel): Promise<VideoModel> {
return video return video
} }
const channelActor = await getOrCreateVideoChannel(videoObject) const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
const account = await AccountModel.load(channelActor.VideoChannel.accountId) const account = await AccountModel.load(channelActor.VideoChannel.accountId)
return updateVideoFromAP(video, videoObject, account.Actor, channelActor) return updateVideoFromAP(video, videoObject, account.Actor, channelActor)
@ -440,7 +440,7 @@ export {
videoActivityObjectToDBAttributes, videoActivityObjectToDBAttributes,
videoFileActivityUrlToDBAttributes, videoFileActivityUrlToDBAttributes,
createVideo, createVideo,
getOrCreateVideoChannel, getOrCreateVideoChannelFromVideoObject,
addVideoShares, addVideoShares,
createRates createRates
} }

View file

@ -38,7 +38,7 @@ const removeFollowingValidator = [
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
const serverActor = await getServerActor() 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) { if (!follow) {
return res return res

View file

@ -5,7 +5,7 @@ import { query } from 'express-validator/check'
import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' 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('search').optional().not().isEmpty().withMessage('Should have a valid search'),
query('startDate').optional().custom(isDateValid).withMessage('Should have a valid start date'), 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'), query('durationMax').optional().isInt().withMessage('Should have a valid max duration'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (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 if (areValidationErrors(req, res)) return
@ -61,5 +73,6 @@ const commonVideosFiltersValidator = [
export { export {
commonVideosFiltersValidator, commonVideosFiltersValidator,
searchValidator videoChannelsSearchValidator,
videosSearchValidator
} }

View 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_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) 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_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) 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 videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_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 videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
@ -45,5 +47,6 @@ export {
followingSortValidator, followingSortValidator,
jobsSortValidator, jobsSortValidator,
videoCommentThreadsSortValidator, videoCommentThreadsSortValidator,
userSubscriptionsSortValidator userSubscriptionsSortValidator,
videoChannelsSearchSortValidator
} }

View file

@ -1,12 +1,13 @@
import * as express from 'express' import * as express from 'express'
import 'express-validator' import 'express-validator'
import { body, param } from 'express-validator/check' import { body, param, query } from 'express-validator/check'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils' import { areValidationErrors } from './utils'
import { ActorFollowModel } from '../../models/activitypub/actor-follow' 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 { UserModel } from '../../models/account/user'
import { CONFIG } from '../../initializers' import { CONFIG } from '../../initializers'
import { toArray } from '../../helpers/custom-validators/misc'
const userSubscriptionAddValidator = [ const userSubscriptionAddValidator = [
body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'), 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 = [ const userSubscriptionGetValidator = [
param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'), 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 if (host === CONFIG.WEBSERVER.HOST) host = null
const user: UserModel = res.locals.oauth.token.User 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) { if (!subscription || !subscription.ActorFollowing.VideoChannel) {
return res return res
@ -51,8 +66,7 @@ const userSubscriptionGetValidator = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
areSubscriptionsExistValidator,
userSubscriptionAddValidator, userSubscriptionAddValidator,
userSubscriptionGetValidator userSubscriptionGetValidator
} }
// ---------------------------------------------------------------------------

View file

@ -29,18 +29,8 @@ import { UserModel } from './user'
@DefaultScope({ @DefaultScope({
include: [ include: [
{ {
model: () => ActorModel, model: () => ActorModel, // Default scope includes avatar and server
required: true, required: true
include: [
{
model: () => ServerModel,
required: false
},
{
model: () => AvatarModel,
required: false
}
]
} }
] ]
}) })

View file

@ -26,7 +26,7 @@ import { ACTOR_FOLLOW_SCORE } from '../../initializers'
import { FOLLOW_STATES } from '../../initializers/constants' import { FOLLOW_STATES } from '../../initializers/constants'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { getSort } from '../utils' import { getSort } from '../utils'
import { ActorModel } from './actor' import { ActorModel, unusedActorAttributesForAPI } from './actor'
import { VideoChannelModel } from '../video/video-channel' import { VideoChannelModel } from '../video/video-channel'
import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions' import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions'
import { AccountModel } from '../account/account' import { AccountModel } from '../account/account'
@ -167,8 +167,11 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
return ActorFollowModel.findOne(query) 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 = { const actorFollowingPartInclude: IIncludeOptions = {
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel, model: ActorModel,
required: true, required: true,
as: 'ActorFollowing', as: 'ActorFollowing',
@ -177,7 +180,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
}, },
include: [ include: [
{ {
model: VideoChannelModel, model: VideoChannelModel.unscoped(),
required: false required: false
} }
] ]
@ -200,17 +203,79 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
actorId actorId
}, },
include: [ include: [
{
model: ActorModel,
required: true,
as: 'ActorFollower'
},
actorFollowingPartInclude actorFollowingPartInclude
], ],
transaction: t transaction: t
} }
return ActorFollowModel.findOne(query) 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) { 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) { static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
const query = { const query = {
attributes: [],
distinct: true, distinct: true,
offset: start, offset: start,
limit: count, limit: count,
@ -257,6 +323,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
}, },
include: [ include: [
{ {
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel, model: ActorModel,
as: 'ActorFollowing', as: 'ActorFollowing',
required: true, required: true,
@ -266,8 +335,24 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
required: true, required: true,
include: [ include: [
{ {
model: AccountModel, attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
required: true required: true
},
{
model: AccountModel,
required: true,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
required: true
}
]
} }
] ]
} }

View file

@ -42,6 +42,16 @@ enum ScopeNames {
FULL = 'FULL' FULL = 'FULL'
} }
export const unusedActorAttributesForAPI = [
'publicKey',
'privateKey',
'inboxUrl',
'outboxUrl',
'sharedInboxUrl',
'followersUrl',
'followingUrl'
]
@DefaultScope({ @DefaultScope({
include: [ include: [
{ {

View file

@ -12,6 +12,7 @@ import {
Is, Is,
Model, Model,
Scopes, Scopes,
Sequelize,
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
@ -24,19 +25,36 @@ import {
} from '../../helpers/custom-validators/video-channels' } from '../../helpers/custom-validators/video-channels'
import { sendDeleteActor } from '../../lib/activitypub/send' import { sendDeleteActor } from '../../lib/activitypub/send'
import { AccountModel } from '../account/account' import { AccountModel } from '../account/account'
import { ActorModel } from '../activitypub/actor' import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
import { getSort, throwIfNotValid } from '../utils' import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
import { VideoModel } from './video' import { VideoModel } from './video'
import { CONSTRAINTS_FIELDS } from '../../initializers' import { CONSTRAINTS_FIELDS } from '../../initializers'
import { AvatarModel } from '../avatar/avatar'
import { ServerModel } from '../server/server' 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 { enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACCOUNT = 'WITH_ACCOUNT',
WITH_ACTOR = 'WITH_ACTOR', WITH_ACTOR = 'WITH_ACTOR',
WITH_VIDEOS = 'WITH_VIDEOS' WITH_VIDEOS = 'WITH_VIDEOS'
} }
type AvailableForListOptions = {
actorId: number
}
@DefaultScope({ @DefaultScope({
include: [ include: [
{ {
@ -46,23 +64,57 @@ enum ScopeNames {
] ]
}) })
@Scopes({ @Scopes({
[ScopeNames.WITH_ACCOUNT]: { [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
include: [ const actorIdNumber = parseInt(options.actorId + '', 10)
{
model: () => AccountModel.unscoped(), // Only list local channels OR channels that are on an instance followed by actorId
required: true, const inQueryInstanceFollow = '(' +
include: [ 'SELECT "actor"."serverId" FROM "actor" ' +
{ 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = actor.id ' +
model: () => ActorModel.unscoped(), 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
required: true, ')'
include: [
return {
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
where: {
[Sequelize.Op.or]: [
{ {
model: () => AvatarModel.unscoped(), serverId: null
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({ @Table({
tableName: 'videoChannel', tableName: 'videoChannel',
indexes: [ indexes
{
fields: [ 'accountId' ]
},
{
fields: [ 'actorId' ]
}
]
}) })
export class VideoChannelModel extends Model<VideoChannelModel> { export class VideoChannelModel extends Model<VideoChannelModel> {
@ -170,15 +215,61 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
return VideoChannelModel.count(query) return VideoChannelModel.count(query)
} }
static listForApi (start: number, count: number, sort: string) { static listForApi (actorId: number, start: number, count: number, sort: string) {
const query = { const query = {
offset: start, offset: start,
limit: count, limit: count,
order: getSort(sort) order: getSort(sort)
} }
const scopes = {
method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ]
}
return VideoChannelModel 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) .findAndCountAll(query)
.then(({ rows, count }) => { .then(({ rows, count }) => {
return { total: count, data: rows } return { total: count, data: rows }
@ -239,7 +330,25 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
} }
return 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) .findOne(query)
} }

View 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 () { describe('When removing a subscription', function () {
it('Should fail with a non authenticated user', async function () { it('Should fail with a non authenticated user', async function () {
await makeDeleteRequest({ await makeDeleteRequest({

View file

@ -12,7 +12,7 @@ import {
listUserSubscriptions, listUserSubscriptions,
listUserSubscriptionVideos, listUserSubscriptionVideos,
removeUserSubscription, removeUserSubscription,
getUserSubscription getUserSubscription, areSubscriptionsExist
} from '../../utils/users/user-subscriptions' } from '../../utils/users/user-subscriptions'
const expect = chai.expect 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 () { it('Should list subscription videos', async function () {
{ {
const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken) const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken)

View 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 { export {
areSubscriptionsExist,
addUserSubscription, addUserSubscription,
listUserSubscriptions, listUserSubscriptions,
getUserSubscription, getUserSubscription,

View file

@ -1,2 +1,3 @@
export * from './nsfw-query.model' export * from './nsfw-query.model'
export * from './videos-search-query.model' export * from './videos-search-query.model'
export * from './video-channels-search-query.model'

View file

@ -0,0 +1,7 @@
export interface VideoChannelsSearchQuery {
search: string
start?: number
count?: number
sort?: string
}