Add ability to search video channels
This commit is contained in:
parent
240085d005
commit
f37dc0dd14
35 changed files with 670 additions and 145 deletions
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
@ -41,6 +41,10 @@
|
||||||
color: $grey-actor-name;
|
color: $grey-actor-name;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-channel-followers {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -127,10 +127,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
my-subscribe-button {
|
my-subscribe-button {
|
||||||
/deep/ span[role=button] {
|
|
||||||
font-size: 13px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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('@')
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,16 @@ enum ScopeNames {
|
||||||
FULL = 'FULL'
|
FULL = 'FULL'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const unusedActorAttributesForAPI = [
|
||||||
|
'publicKey',
|
||||||
|
'privateKey',
|
||||||
|
'inboxUrl',
|
||||||
|
'outboxUrl',
|
||||||
|
'sharedInboxUrl',
|
||||||
|
'followersUrl',
|
||||||
|
'followingUrl'
|
||||||
|
]
|
||||||
|
|
||||||
@DefaultScope({
|
@DefaultScope({
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface VideoChannelsSearchQuery {
|
||||||
|
search: string
|
||||||
|
|
||||||
|
start?: number
|
||||||
|
count?: number
|
||||||
|
sort?: string
|
||||||
|
}
|
Loading…
Reference in a new issue