From 2760b454a761f6af3138b2fb5f34340772ab0d1e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 27 Oct 2021 14:37:04 +0200 Subject: [PATCH] Deprecate filter video query Introduce include and isLocal instead --- .../overview/videos/video-list.component.html | 13 +- .../overview/videos/video-list.component.scss | 2 + .../overview/videos/video-list.component.ts | 32 +- .../information/video-alert.component.html | 2 +- .../videos-list-common-page.component.ts | 2 +- .../custom-markup.service.ts | 3 +- .../videos-list-markup.component.ts | 6 +- .../shared/shared-main/video/video.model.ts | 11 +- .../shared/shared-main/video/video.service.ts | 34 +- .../video-block.component.ts | 2 +- .../video-actions-dropdown.component.ts | 2 +- .../video-filters.model.ts | 22 +- .../video-miniature.component.html | 2 +- server/controllers/api/accounts.ts | 18 +- server/controllers/api/overviews.ts | 8 +- .../controllers/api/search/search-videos.ts | 12 +- .../controllers/api/users/my-subscriptions.ts | 9 +- server/controllers/api/video-channel.ts | 17 +- server/controllers/api/videos/index.ts | 10 +- server/controllers/bots.ts | 10 +- server/controllers/feeds.ts | 21 +- server/helpers/custom-validators/videos.ts | 6 + server/helpers/query.ts | 7 +- .../middlewares/validators/videos/videos.ts | 31 +- server/models/account/account.ts | 6 +- server/models/server/server.ts | 4 +- server/models/user/user-video-history.ts | 10 +- .../video/formatter/video-format-utils.ts | 68 ++- .../abstract-videos-model-query-builder.ts | 28 ++ .../video/sql/shared/video-model-builder.ts | 72 +++- .../models/video/sql/shared/video-tables.ts | 4 + .../sql/video-model-get-query-builder.ts | 6 +- .../video/sql/videos-id-list-query-builder.ts | 60 ++- .../sql/videos-model-list-query-builder.ts | 11 +- server/models/video/video.ts | 86 ++-- server/tests/api/check-params/index.ts | 2 +- .../api/check-params/videos-common-filters.ts | 203 +++++++++ .../tests/api/check-params/videos-filter.ts | 114 ----- server/tests/api/videos/index.ts | 2 +- server/tests/api/videos/multiple-servers.ts | 6 +- server/tests/api/videos/single-server.ts | 13 - .../tests/api/videos/videos-common-filters.ts | 403 ++++++++++++++++++ server/tests/api/videos/videos-filter.ts | 122 ------ server/types/models/account/account.ts | 2 +- shared/extra-utils/videos/videos-command.ts | 10 +- .../search/videos-common-query.model.ts | 17 +- .../search/videos-search-query.model.ts | 3 + shared/models/videos/index.ts | 3 +- ...deo-query.type.ts => video-filter.type.ts} | 0 shared/models/videos/video-include.enum.ts | 7 + shared/models/videos/video.model.ts | 20 +- support/doc/api/openapi.yaml | 60 ++- 52 files changed, 1135 insertions(+), 489 deletions(-) create mode 100644 server/tests/api/check-params/videos-common-filters.ts delete mode 100644 server/tests/api/check-params/videos-filter.ts create mode 100644 server/tests/api/videos/videos-common-filters.ts delete mode 100644 server/tests/api/videos/videos-filter.ts rename shared/models/videos/{video-query.type.ts => video-filter.type.ts} (100%) create mode 100644 shared/models/videos/video-include.enum.ts diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html index 1f1e9cc6e..6250c00fb 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.html +++ b/client/src/app/+admin/overview/videos/video-list.component.html @@ -63,10 +63,17 @@ - - {{ video.privacy.label }} + + {{ video.privacy.label }} + NSFW - NSFW + + Not published yet + + Account muted + Server muted + + Blocked diff --git a/client/src/app/+admin/overview/videos/video-list.component.scss b/client/src/app/+admin/overview/videos/video-list.component.scss index fcdb457f2..250a917e4 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.scss +++ b/client/src/app/+admin/overview/videos/video-list.component.scss @@ -7,4 +7,6 @@ my-embed { .badge { @include table-badge; + + margin-right: 5px; } diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts index a445bc209..dd9225e6a 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' -import { UserRight } from '@shared/models' +import { UserRight, VideoPrivacy, VideoState } from '@shared/models' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { VideoActionsDisplayType } from '@app/shared/shared-video-miniature' @@ -28,8 +28,12 @@ export class VideoListComponent extends RestTable implements OnInit { title: $localize`Advanced filters`, children: [ { - queryParams: { search: 'local:true' }, - label: $localize`Only local videos` + queryParams: { search: 'isLocal:false' }, + label: $localize`Remote videos` + }, + { + queryParams: { search: 'isLocal:true' }, + label: $localize`Local videos` } ] } @@ -88,6 +92,28 @@ export class VideoListComponent extends RestTable implements OnInit { this.reloadData() } + getPrivacyBadgeClass (privacy: VideoPrivacy) { + if (privacy === VideoPrivacy.PUBLIC) return 'badge-blue' + + return 'badge-yellow' + } + + isUnpublished (state: VideoState) { + return state !== VideoState.PUBLISHED + } + + isAccountBlocked (video: Video) { + return video.blockedOwner + } + + isServerBlocked (video: Video) { + return video.blockedServer + } + + isVideoBlocked (video: Video) { + return video.blacklisted + } + protected reloadData () { this.selectedVideos = [] diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html index e2dd44bf7..33b5a47a0 100644 --- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html +++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html @@ -24,5 +24,5 @@
This video is blocked.
- {{ video.blockedReason }} + {{ video.blacklistedReason }}
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.ts b/client/src/app/+videos/video-list/videos-list-common-page.component.ts index ba64d4fec..d03b09610 100644 --- a/client/src/app/+videos/video-list/videos-list-common-page.component.ts +++ b/client/src/app/+videos/video-list/videos-list-common-page.component.ts @@ -85,7 +85,7 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable getSyndicationItems (filters: VideoFilters) { const result = filters.toVideosAPIObject() - return this.videoService.getVideoFeedUrls(result.sort, result.filter) + return this.videoService.getVideoFeedUrls(result.sort, result.isLocal) } onFiltersChanged (filters: VideoFilters) { diff --git a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts index ab640d348..a959b336d 100644 --- a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts +++ b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts @@ -7,7 +7,6 @@ import { ContainerMarkupData, EmbedMarkupData, PlaylistMiniatureMarkupData, - VideoFilter, VideoMiniatureMarkupData, VideosListMarkupData } from '@shared/models' @@ -193,7 +192,7 @@ export class CustomMarkupService { isLive: this.buildBoolean(data.isLive), - filter: this.buildBoolean(data.onlyLocal) ? 'local' as VideoFilter : undefined + isLocal: this.buildBoolean(data.onlyLocal) ? true : undefined } this.dynamicElementService.setModel(component, model) diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts index 856e43681..0e4d5fb12 100644 --- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts +++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts @@ -1,7 +1,7 @@ import { finalize } from 'rxjs/operators' import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' import { AuthService, Notifier } from '@app/core' -import { VideoFilter, VideoSortField } from '@shared/models' +import { VideoSortField } from '@shared/models' import { Video, VideoService } from '../../shared-main' import { MiniatureDisplayOptions } from '../../shared-video-miniature' import { CustomMarkupComponent } from './shared' @@ -21,7 +21,7 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit @Input() languageOneOf: string[] @Input() count: number @Input() onlyDisplayTitle: boolean - @Input() filter: VideoFilter + @Input() isLocal: boolean @Input() isLive: boolean @Input() maxRows: number @Input() channelHandle: string @@ -86,7 +86,7 @@ export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit }, categoryOneOf: this.categoryOneOf, languageOneOf: this.languageOneOf, - filter: this.filter, + isLocal: this.isLocal, isLive: this.isLive, sort: this.sort as VideoSortField, account: { nameWithHost: this.accountHandle }, diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 10caec014..699eac7f1 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -65,8 +65,12 @@ export class Video implements VideoServerModel { waitTranscoding?: boolean state?: VideoConstant scheduledUpdate?: VideoScheduleUpdate + blacklisted?: boolean - blockedReason?: string + blacklistedReason?: string + + blockedOwner?: boolean + blockedServer?: boolean account: { id: number @@ -163,7 +167,10 @@ export class Video implements VideoServerModel { if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) this.blacklisted = hash.blacklisted - this.blockedReason = hash.blacklistedReason + this.blacklistedReason = hash.blacklistedReason + + this.blockedOwner = hash.blockedOwner + this.blockedServer = hash.blockedServer this.userHistory = hash.userHistory diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 9e3aa1e6a..0a3a51b0c 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -18,7 +18,7 @@ import { VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFileMetadata, - VideoFilter, + VideoInclude, VideoPrivacy, VideoSortField, VideoUpdate @@ -34,11 +34,13 @@ import { Video } from './video.model' export type CommonVideoParams = { videoPagination?: ComponentPaginationLight sort: VideoSortField | SortMeta - filter?: VideoFilter + include?: VideoInclude + isLocal?: boolean categoryOneOf?: number[] languageOneOf?: string[] isLive?: boolean skipCount?: boolean + // FIXME: remove? nsfwPolicy?: NSFWPolicyType nsfw?: BooleanBothQuery @@ -202,12 +204,14 @@ export class VideoService { } getAdminVideos ( - parameters: Omit & { pagination: RestPagination, search?: string } + parameters: CommonVideoParams & { pagination: RestPagination, search?: string } ): Observable> { const { pagination, search } = parameters + const include = VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER | VideoInclude.HIDDEN_PRIVACY | VideoInclude.NOT_PUBLISHED_STATE + let params = new HttpParams() - params = this.buildCommonVideosParams({ params, ...parameters }) + params = this.buildCommonVideosParams({ params, include, ...parameters }) params = params.set('start', pagination.start.toString()) .set('count', pagination.count.toString()) @@ -216,8 +220,6 @@ export class VideoService { params = this.buildAdminParamsFromSearch(search, params) } - if (!params.has('filter')) params = params.set('filter', 'all') - return this.authHttp .get>(VideoService.BASE_VIDEO_URL, { params }) .pipe( @@ -266,10 +268,10 @@ export class VideoService { return feeds } - getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) { + getVideoFeedUrls (sort: VideoSortField, isLocal: boolean, categoryOneOf?: number[]) { let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort) - if (filter) params = params.set('filter', filter) + if (isLocal) params = params.set('isLocal', isLocal) if (categoryOneOf) { for (const c of categoryOneOf) { @@ -425,7 +427,8 @@ export class VideoService { params, videoPagination, sort, - filter, + isLocal, + include, categoryOneOf, languageOneOf, skipCount, @@ -440,9 +443,10 @@ export class VideoService { let newParams = this.restService.addRestGetParams(params, pagination, sort) - if (filter) newParams = newParams.set('filter', filter) if (skipCount) newParams = newParams.set('skipCount', skipCount + '') + if (isLocal) newParams = newParams.set('isLocal', isLocal) + if (include) newParams = newParams.set('include', include) if (isLive) newParams = newParams.set('isLive', isLive) if (nsfw) newParams = newParams.set('nsfw', nsfw) if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) @@ -454,13 +458,9 @@ export class VideoService { private buildAdminParamsFromSearch (search: string, params: HttpParams) { const filters = this.restService.parseQueryStringFilter(search, { - filter: { - prefix: 'local:', - handler: v => { - if (v === 'true') return 'all-local' - - return 'all' - } + isLocal: { + prefix: 'isLocal:', + isBoolean: true } }) diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts index f6c29dcfa..a6180dd14 100644 --- a/client/src/app/shared/shared-moderation/video-block.component.ts +++ b/client/src/app/shared/shared-moderation/video-block.component.ts @@ -61,7 +61,7 @@ export class VideoBlockComponent extends FormReactive implements OnInit { this.hide() this.video.blacklisted = true - this.video.blockedReason = reason + this.video.blacklistedReason = reason this.videoBlocked.emit() }, diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts index 2ba091438..feac79d4e 100644 --- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts @@ -188,7 +188,7 @@ export class VideoActionsDropdownComponent implements OnChanges { this.notifier.success($localize`Video ${this.video.name} unblocked.`) this.video.blacklisted = false - this.video.blockedReason = null + this.video.blacklistedReason = null this.videoUnblocked.emit() }, diff --git a/client/src/app/shared/shared-video-miniature/video-filters.model.ts b/client/src/app/shared/shared-video-miniature/video-filters.model.ts index 920dc826c..5ad7cf3f7 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters.model.ts +++ b/client/src/app/shared/shared-video-miniature/video-filters.model.ts @@ -1,6 +1,6 @@ import { intoArray, toBoolean } from '@app/helpers' import { AttributesOnly } from '@shared/core-utils' -import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models' +import { BooleanBothQuery, NSFWPolicyType, VideoInclude, VideoSortField } from '@shared/models' type VideoFiltersKeys = { [ id in keyof AttributesOnly ]: any @@ -196,14 +196,15 @@ export class VideoFilters { } toVideosAPIObject () { - let filter: VideoFilter - - if (this.scope === 'local' && this.allVideos) { - filter = 'all-local' - } else if (this.scope === 'federated' && this.allVideos) { - filter = 'all' - } else if (this.scope === 'local') { - filter = 'local' + let isLocal: boolean + let include: VideoInclude + + if (this.scope === 'local') { + isLocal = true + } + + if (this.allVideos) { + include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.HIDDEN_PRIVACY } let isLive: boolean @@ -216,7 +217,8 @@ export class VideoFilters { languageOneOf: this.languageOneOf, categoryOneOf: this.categoryOneOf, search: this.search, - filter, + isLocal, + include, isLive } } diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html index b12495f90..30483831a 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html @@ -55,7 +55,7 @@
Blocked - {{ video.blockedReason }} + {{ video.blacklistedReason }}
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 8eb880d59..44edffe38 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -2,6 +2,7 @@ import express from 'express' import { pickCommonVideoQuery } from '@server/helpers/query' import { ActorFollowModel } from '@server/models/actor/actor-follow' import { getServerActor } from '@server/models/application/application' +import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { getFormattedObjects } from '../../helpers/utils' import { JobQueue } from '../../lib/job-queue' @@ -169,17 +170,24 @@ async function listAccountPlaylists (req: express.Request, res: express.Response } async function listAccountVideos (req: express.Request, res: express.Response) { + const serverActor = await getServerActor() + const account = res.locals.account - const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined + + const displayOnlyForFollower = isUserAbleToSearchRemoteURI(res) + ? null + : { + actorId: serverActor.id, + orLocalVideos: true + } + const countVideos = getCountVideos(req) const query = pickCommonVideoQuery(req.query) const apiOptions = await Hooks.wrapObject({ ...query, - followerActorId, - search: req.query.search, - includeLocalVideos: true, + displayOnlyForFollower, nsfw: buildNSFWFilter(res, query.nsfw), withFiles: false, accountId: account.id, @@ -193,7 +201,7 @@ async function listAccountVideos (req: express.Request, res: express.Response) { 'filter:api.accounts.videos.list.result' ) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query))) } async function listAccountRatings (req: express.Request, res: express.Response) { diff --git a/server/controllers/api/overviews.ts b/server/controllers/api/overviews.ts index 5b16232e2..68626a508 100644 --- a/server/controllers/api/overviews.ts +++ b/server/controllers/api/overviews.ts @@ -8,6 +8,7 @@ import { buildNSFWFilter } from '../../helpers/express-utils' import { MEMOIZE_TTL, OVERVIEWS } from '../../initializers/constants' import { asyncMiddleware, optionalAuthenticate, videosOverviewValidator } from '../../middlewares' import { TagModel } from '../../models/video/tag' +import { getServerActor } from '@server/models/application/application' const overviewsRouter = express.Router() @@ -109,11 +110,16 @@ async function getVideos ( res: express.Response, where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] } ) { + const serverActor = await getServerActor() + const query = await Hooks.wrapObject({ start: 0, count: 12, sort: '-createdAt', - includeLocalVideos: true, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + }, nsfw: buildNSFWFilter(res), user: res.locals.oauth ? res.locals.oauth.token.User : undefined, withFiles: false, diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts index 90946cb74..6db70acdf 100644 --- a/server/controllers/api/search/search-videos.ts +++ b/server/controllers/api/search/search-videos.ts @@ -7,6 +7,8 @@ import { WEBSERVER } from '@server/initializers/constants' import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' import { Hooks } from '@server/lib/plugins/hooks' import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' +import { getServerActor } from '@server/models/application/application' +import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { HttpStatusCode, ResultList, Video } from '@shared/models' import { VideosSearchQueryAfterSanitize } from '../../../../shared/models/search' import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' @@ -100,11 +102,15 @@ async function searchVideosIndex (query: VideosSearchQueryAfterSanitize, res: ex } async function searchVideosDB (query: VideosSearchQueryAfterSanitize, res: express.Response) { + const serverActor = await getServerActor() + const apiOptions = await Hooks.wrapObject({ ...query, - includeLocalVideos: true, - filter: query.filter, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + }, nsfw: buildNSFWFilter(res, query.nsfw), user: res.locals.oauth @@ -118,7 +124,7 @@ async function searchVideosDB (query: VideosSearchQueryAfterSanitize, res: expre 'filter:api.search.videos.local.list.result' ) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query))) } async function searchVideoURI (url: string, res: express.Response) { diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts index b2b441673..d96378180 100644 --- a/server/controllers/api/users/my-subscriptions.ts +++ b/server/controllers/api/users/my-subscriptions.ts @@ -2,6 +2,7 @@ import 'multer' import express from 'express' import { pickCommonVideoQuery } from '@server/helpers/query' import { sendUndoFollow } from '@server/lib/activitypub/send' +import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { VideoChannelModel } from '@server/models/video/video-channel' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' @@ -175,13 +176,15 @@ async function getUserSubscriptionVideos (req: express.Request, res: express.Res const resultList = await VideoModel.listForApi({ ...query, - includeLocalVideos: false, + displayOnlyForFollower: { + actorId: user.Account.Actor.id, + orLocalVideos: false + }, nsfw: buildNSFWFilter(res, query.nsfw), withFiles: false, - followerActorId: user.Account.Actor.id, user, countVideos }) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query))) } diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 7bf7a68c9..f9c1a405d 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -3,6 +3,7 @@ import { pickCommonVideoQuery } from '@server/helpers/query' import { Hooks } from '@server/lib/plugins/hooks' import { ActorFollowModel } from '@server/models/actor/actor-follow' import { getServerActor } from '@server/models/application/application' +import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { MChannelBannerAccountDefault } from '@server/types/models' import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared' import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' @@ -327,16 +328,24 @@ async function listVideoChannelPlaylists (req: express.Request, res: express.Res } async function listVideoChannelVideos (req: express.Request, res: express.Response) { + const serverActor = await getServerActor() + const videoChannelInstance = res.locals.videoChannel - const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined + + const displayOnlyForFollower = isUserAbleToSearchRemoteURI(res) + ? null + : { + actorId: serverActor.id, + orLocalVideos: true + } + const countVideos = getCountVideos(req) const query = pickCommonVideoQuery(req.query) const apiOptions = await Hooks.wrapObject({ ...query, - followerActorId, - includeLocalVideos: true, + displayOnlyForFollower, nsfw: buildNSFWFilter(res, query.nsfw), withFiles: false, videoChannelId: videoChannelInstance.id, @@ -350,7 +359,7 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon 'filter:api.video-channels.videos.list.result' ) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query))) } async function listVideoChannelFollowers (req: express.Request, res: express.Response) { diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index c0c77f3f7..821ed7ff3 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -5,6 +5,7 @@ import { doJSONRequest } from '@server/helpers/requests' import { LiveManager } from '@server/lib/live' import { openapiOperationDoc } from '@server/middlewares/doc' import { getServerActor } from '@server/models/application/application' +import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { MVideoAccountLight } from '@server/types/models' import { HttpStatusCode } from '../../../../shared/models' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' @@ -211,13 +212,18 @@ async function getVideoFileMetadata (req: express.Request, res: express.Response } async function listVideos (req: express.Request, res: express.Response) { + const serverActor = await getServerActor() + const query = pickCommonVideoQuery(req.query) const countVideos = getCountVideos(req) const apiOptions = await Hooks.wrapObject({ ...query, - includeLocalVideos: true, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + }, nsfw: buildNSFWFilter(res, query.nsfw), withFiles: false, user: res.locals.oauth ? res.locals.oauth.token.User : undefined, @@ -230,7 +236,7 @@ async function listVideos (req: express.Request, res: express.Response) { 'filter:api.videos.list.result' ) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query))) } async function removeVideo (_req: express.Request, res: express.Response) { diff --git a/server/controllers/bots.ts b/server/controllers/bots.ts index 63db345bf..9f03de7e8 100644 --- a/server/controllers/bots.ts +++ b/server/controllers/bots.ts @@ -1,3 +1,4 @@ +import { getServerActor } from '@server/models/application/application' import express from 'express' import { truncate } from 'lodash' import { SitemapStream, streamToPromise } from 'sitemap' @@ -63,13 +64,18 @@ async function getSitemapAccountUrls () { } async function getSitemapLocalVideoUrls () { + const serverActor = await getServerActor() + const { data } = await VideoModel.listForApi({ start: 0, count: undefined, sort: 'createdAt', - includeLocalVideos: true, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + }, + isLocal: true, nsfw: buildNSFWFilter(), - filter: 'local', withFiles: false, countVideos: false }) diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index 5ac2e43a1..1f6aebac3 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts @@ -1,7 +1,7 @@ import express from 'express' import Feed from 'pfeed' +import { getServerActor } from '@server/models/application/application' import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' -import { VideoFilter } from '../../shared/models/videos/video-query.type' import { buildNSFWFilter } from '../helpers/express-utils' import { CONFIG } from '../initializers/config' import { FEEDS, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' @@ -160,13 +160,18 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { videoChannelId: videoChannel ? videoChannel.id : null } + const server = await getServerActor() const { data } = await VideoModel.listForApi({ start, count: FEEDS.COUNT, sort: req.query.sort, - includeLocalVideos: true, + displayOnlyForFollower: { + actorId: server.id, + orLocalVideos: true + }, nsfw, - filter: req.query.filter as VideoFilter, + isLocal: req.query.isLocal, + include: req.query.include, withFiles: true, countVideos: false, ...options @@ -196,14 +201,18 @@ async function generateVideoFeedForSubscriptions (req: express.Request, res: exp start, count: FEEDS.COUNT, sort: req.query.sort, - includeLocalVideos: false, nsfw, - filter: req.query.filter as VideoFilter, + + isLocal: req.query.isLocal, + include: req.query.include, withFiles: true, countVideos: false, - followerActorId: res.locals.user.Account.Actor.id, + displayOnlyForFollower: { + actorId: res.locals.user.Account.Actor.id, + orLocalVideos: false + }, user: res.locals.user }) diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index c3604fbad..1d56ade6f 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -2,6 +2,7 @@ import { UploadFilesForCheck } from 'express' import { values } from 'lodash' import magnetUtil from 'magnet-uri' import validator from 'validator' +import { VideoInclude } from '@shared/models' import { VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared' import { CONSTRAINTS_FIELDS, @@ -21,6 +22,10 @@ function isVideoFilterValid (filter: VideoFilter) { return filter === 'local' || filter === 'all-local' || filter === 'all' } +function isVideoIncludeValid (include: VideoInclude) { + return exists(include) && validator.isInt('' + include) +} + function isVideoCategoryValid (value: any) { return value === null || VIDEO_CATEGORIES[value] !== undefined } @@ -146,6 +151,7 @@ export { isVideoOriginallyPublishedAtValid, isVideoMagnetUriValid, isVideoStateValid, + isVideoIncludeValid, isVideoViewsValid, isVideoRatingTypeValid, isVideoFileExtnameValid, diff --git a/server/helpers/query.ts b/server/helpers/query.ts index e711b15f2..79cf076d1 100644 --- a/server/helpers/query.ts +++ b/server/helpers/query.ts @@ -18,8 +18,10 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) { 'languageOneOf', 'tagsOneOf', 'tagsAllOf', - 'filter', - 'skipCount' + 'isLocal', + 'include', + 'skipCount', + 'search' ]) } @@ -29,7 +31,6 @@ function pickSearchVideoQuery (query: VideosSearchQueryAfterSanitize) { ...pick(query, [ 'searchTarget', - 'search', 'host', 'startDate', 'endDate', diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index e486887a7..44233b653 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -7,6 +7,7 @@ import { isAbleToUploadVideo } from '@server/lib/user' import { getServerActor } from '@server/models/application/application' import { ExpressPromiseHandler } from '@server/types/express' import { MUserAccountId, MVideoFullLight } from '@server/types/models' +import { VideoInclude } from '@shared/models' import { ServerErrorCode, UserRight, VideoPrivacy } from '../../../../shared' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { @@ -30,6 +31,7 @@ import { isVideoFileSizeValid, isVideoFilterValid, isVideoImage, + isVideoIncludeValid, isVideoLanguageValid, isVideoLicenceValid, isVideoNameValid, @@ -487,6 +489,13 @@ const commonVideosFiltersValidator = [ query('filter') .optional() .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'), + query('include') + .optional() + .custom(isVideoIncludeValid).withMessage('Should have a valid include attribute'), + query('isLocal') + .optional() + .customSanitizer(toBooleanOrNull) + .custom(isBooleanValid).withMessage('Should have a valid local boolean'), query('skipCount') .optional() .customSanitizer(toBooleanOrNull) @@ -500,11 +509,23 @@ const commonVideosFiltersValidator = [ if (areValidationErrors(req, res)) return - const user = res.locals.oauth ? res.locals.oauth.token.User : undefined - if ( - (req.query.filter === 'all-local' || req.query.filter === 'all') && - (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false) - ) { + // FIXME: deprecated in 4.0, to remove + { + if (req.query.filter === 'all-local') { + req.query.include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.HIDDEN_PRIVACY + req.query.isLocal = true + } else if (req.query.filter === 'all') { + req.query.include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.HIDDEN_PRIVACY + } else if (req.query.filter === 'local') { + req.query.isLocal = true + } + + req.query.filter = undefined + } + + const user = res.locals.oauth?.token.User + + if (req.query.include && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) { res.fail({ status: HttpStatusCode.UNAUTHORIZED_401, message: 'You are not allowed to see all local videos.' diff --git a/server/models/account/account.ts b/server/models/account/account.ts index 37194a119..056ec6857 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts @@ -228,10 +228,10 @@ export class AccountModel extends Model>> { name: 'targetAccountId', allowNull: false }, - as: 'BlockedAccounts', + as: 'BlockedBy', onDelete: 'CASCADE' }) - BlockedAccounts: AccountBlocklistModel[] + BlockedBy: AccountBlocklistModel[] @BeforeDestroy static async sendDeleteIfOwned (instance: AccountModel, options) { @@ -457,6 +457,6 @@ export class AccountModel extends Model>> { } isBlocked () { - return this.BlockedAccounts && this.BlockedAccounts.length !== 0 + return this.BlockedBy && this.BlockedBy.length !== 0 } } diff --git a/server/models/server/server.ts b/server/models/server/server.ts index 0d3c092e0..edbe92f73 100644 --- a/server/models/server/server.ts +++ b/server/models/server/server.ts @@ -50,7 +50,7 @@ export class ServerModel extends Model>> { }, onDelete: 'CASCADE' }) - BlockedByAccounts: ServerBlocklistModel[] + BlockedBy: ServerBlocklistModel[] static load (id: number, transaction?: Transaction): Promise { const query = { @@ -81,7 +81,7 @@ export class ServerModel extends Model>> { } isBlocked () { - return this.BlockedByAccounts && this.BlockedByAccounts.length !== 0 + return this.BlockedBy && this.BlockedBy.length !== 0 } toFormattedJSON (this: MServerFormattable) { diff --git a/server/models/user/user-video-history.ts b/server/models/user/user-video-history.ts index e3dc4a062..d633cc9d5 100644 --- a/server/models/user/user-video-history.ts +++ b/server/models/user/user-video-history.ts @@ -4,6 +4,7 @@ import { MUserAccountId, MUserId } from '@server/types/models' import { AttributesOnly } from '@shared/core-utils' import { VideoModel } from '../video/video' import { UserModel } from './user' +import { getServerActor } from '../application/application' @Table({ tableName: 'userVideoHistory', @@ -56,14 +57,19 @@ export class UserVideoHistoryModel extends ModelAccount->AccountBlocklist" ' + + 'ON "VideoChannel->Account"."id" = "VideoChannel->Account->AccountBlocklist"."targetAccountId" ' + + 'AND "VideoChannel->Account->AccountBlocklist"."accountId" IN (' + inClause + ')' + ) + + this.addJoin( + 'LEFT JOIN "serverBlocklist" AS "VideoChannel->Account->Actor->Server->ServerBlocklist" ' + + 'ON "VideoChannel->Account->Actor->Server->ServerBlocklist"."targetServerId" = "VideoChannel->Account->Actor"."serverId" ' + + 'AND "VideoChannel->Account->Actor->Server->ServerBlocklist"."accountId" IN (' + inClause + ') ' + ) + + this.attributes = { + ...this.attributes, + + ...this.buildAttributesObject('VideoChannel->Account->AccountBlocklist', this.tables.getBlocklistAttributes()), + ...this.buildAttributesObject('VideoChannel->Account->Actor->Server->ServerBlocklist', this.tables.getBlocklistAttributes()) + } + } + protected includeScheduleUpdate () { this.addJoin( 'LEFT OUTER JOIN "scheduleVideoUpdate" AS "ScheduleVideoUpdate" ON "video"."id" = "ScheduleVideoUpdate"."videoId"' diff --git a/server/models/video/sql/shared/video-model-builder.ts b/server/models/video/sql/shared/video-model-builder.ts index 33a0181e9..0eac95661 100644 --- a/server/models/video/sql/shared/video-model-builder.ts +++ b/server/models/video/sql/shared/video-model-builder.ts @@ -1,11 +1,14 @@ import { AccountModel } from '@server/models/account/account' +import { AccountBlocklistModel } from '@server/models/account/account-blocklist' import { ActorModel } from '@server/models/actor/actor' import { ActorImageModel } from '@server/models/actor/actor-image' import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy' import { ServerModel } from '@server/models/server/server' +import { ServerBlocklistModel } from '@server/models/server/server-blocklist' import { TrackerModel } from '@server/models/server/tracker' import { UserVideoHistoryModel } from '@server/models/user/user-video-history' +import { VideoInclude } from '@shared/models' import { ScheduleVideoUpdateModel } from '../../schedule-video-update' import { TagModel } from '../../tag' import { ThumbnailModel } from '../../thumbnail' @@ -33,6 +36,8 @@ export class VideoModelBuilder { private thumbnailsDone: Set private historyDone: Set private blacklistDone: Set + private accountBlocklistDone: Set + private serverBlocklistDone: Set private liveDone: Set private redundancyDone: Set private scheduleVideoUpdateDone: Set @@ -51,7 +56,14 @@ export class VideoModelBuilder { } - buildVideosFromRows (rows: SQLRow[], rowsWebTorrentFiles?: SQLRow[], rowsStreamingPlaylist?: SQLRow[]) { + buildVideosFromRows (options: { + rows: SQLRow[] + include?: VideoInclude + rowsWebTorrentFiles?: SQLRow[] + rowsStreamingPlaylist?: SQLRow[] + }) { + const { rows, rowsWebTorrentFiles, rowsStreamingPlaylist, include } = options + this.reinit() for (const row of rows) { @@ -77,6 +89,15 @@ export class VideoModelBuilder { this.setBlacklisted(row, videoModel) this.setScheduleVideoUpdate(row, videoModel) this.setLive(row, videoModel) + } else { + if (include & VideoInclude.BLACKLISTED) { + this.setBlacklisted(row, videoModel) + } + + if (include & VideoInclude.BLOCKED_OWNER) { + this.setBlockedOwner(row, videoModel) + this.setBlockedServer(row, videoModel) + } } } @@ -91,15 +112,18 @@ export class VideoModelBuilder { this.videoStreamingPlaylistMemo = {} this.videoFileMemo = {} - this.thumbnailsDone = new Set() - this.historyDone = new Set() - this.blacklistDone = new Set() - this.liveDone = new Set() - this.redundancyDone = new Set() - this.scheduleVideoUpdateDone = new Set() + this.thumbnailsDone = new Set() + this.historyDone = new Set() + this.blacklistDone = new Set() + this.liveDone = new Set() + this.redundancyDone = new Set() + this.scheduleVideoUpdateDone = new Set() + + this.accountBlocklistDone = new Set() + this.serverBlocklistDone = new Set() - this.trackersDone = new Set() - this.tagsDone = new Set() + this.trackersDone = new Set() + this.tagsDone = new Set() this.videos = [] } @@ -162,6 +186,8 @@ export class VideoModelBuilder { const accountModel = new AccountModel(this.grab(row, this.tables.getAccountAttributes(), 'VideoChannel.Account'), this.buildOpts) accountModel.Actor = this.buildActor(row, 'VideoChannel.Account') + accountModel.BlockedBy = [] + channelModel.Account = accountModel videoModel.VideoChannel = channelModel @@ -180,6 +206,8 @@ export class VideoModelBuilder { ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts) : null + if (serverModel) serverModel.BlockedBy = [] + const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts) actorModel.Avatar = avatarModel actorModel.Server = serverModel @@ -297,6 +325,32 @@ export class VideoModelBuilder { this.blacklistDone.add(id) } + private setBlockedOwner (row: SQLRow, videoModel: VideoModel) { + const id = row['VideoChannel.Account.AccountBlocklist.id'] + if (!id) return + + const key = `${videoModel.id}-${id}` + if (this.accountBlocklistDone.has(key)) return + + const attributes = this.grab(row, this.tables.getBlocklistAttributes(), 'VideoChannel.Account.AccountBlocklist') + videoModel.VideoChannel.Account.BlockedBy.push(new AccountBlocklistModel(attributes, this.buildOpts)) + + this.accountBlocklistDone.add(key) + } + + private setBlockedServer (row: SQLRow, videoModel: VideoModel) { + const id = row['VideoChannel.Account.Actor.Server.ServerBlocklist.id'] + if (!id || this.serverBlocklistDone.has(id)) return + + const key = `${videoModel.id}-${id}` + if (this.serverBlocklistDone.has(key)) return + + const attributes = this.grab(row, this.tables.getBlocklistAttributes(), 'VideoChannel.Account.Actor.Server.ServerBlocklist') + videoModel.VideoChannel.Account.Actor.Server.BlockedBy.push(new ServerBlocklistModel(attributes, this.buildOpts)) + + this.serverBlocklistDone.add(key) + } + private setScheduleVideoUpdate (row: SQLRow, videoModel: VideoModel) { const id = row['ScheduleVideoUpdate.id'] if (!id || this.scheduleVideoUpdateDone.has(id)) return diff --git a/server/models/video/sql/shared/video-tables.ts b/server/models/video/sql/shared/video-tables.ts index 75823864d..042b9d5da 100644 --- a/server/models/video/sql/shared/video-tables.ts +++ b/server/models/video/sql/shared/video-tables.ts @@ -139,6 +139,10 @@ export class VideoTables { return [ 'id', 'reason', 'unfederated' ] } + getBlocklistAttributes () { + return [ 'id' ] + } + getScheduleUpdateAttributes () { return [ 'id', diff --git a/server/models/video/sql/video-model-get-query-builder.ts b/server/models/video/sql/video-model-get-query-builder.ts index f234e8778..d18ddae67 100644 --- a/server/models/video/sql/video-model-get-query-builder.ts +++ b/server/models/video/sql/video-model-get-query-builder.ts @@ -62,7 +62,11 @@ export class VideosModelGetQueryBuilder { : Promise.resolve(undefined) ]) - const videos = this.videoModelBuilder.buildVideosFromRows(videoRows, webtorrentFilesRows, streamingPlaylistFilesRows) + const videos = this.videoModelBuilder.buildVideosFromRows({ + rows: videoRows, + rowsWebTorrentFiles: webtorrentFilesRows, + rowsStreamingPlaylist: streamingPlaylistFilesRows + }) if (videos.length > 1) { throw new Error('Video results is more than ') diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/videos-id-list-query-builder.ts index 7625c003d..3eb547e75 100644 --- a/server/models/video/sql/videos-id-list-query-builder.ts +++ b/server/models/video/sql/videos-id-list-query-builder.ts @@ -4,7 +4,7 @@ import { exists } from '@server/helpers/custom-validators/misc' import { WEBSERVER } from '@server/initializers/constants' import { buildDirectionAndField, createSafeIn } from '@server/models/utils' import { MUserAccountId, MUserId } from '@server/types/models' -import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models' +import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' import { AbstractVideosQueryBuilder } from './shared/abstract-videos-query-builder' /** @@ -13,21 +13,27 @@ import { AbstractVideosQueryBuilder } from './shared/abstract-videos-query-build * */ +export type DisplayOnlyForFollowerOptions = { + actorId: number + orLocalVideos: boolean +} + export type BuildVideosListQueryOptions = { attributes?: string[] - serverAccountId: number - followerActorId: number - includeLocalVideos: boolean + serverAccountIdForBlock: number + + displayOnlyForFollower: DisplayOnlyForFollowerOptions count: number start: number sort: string nsfw?: boolean - filter?: VideoFilter host?: string isLive?: boolean + isLocal?: boolean + include?: VideoInclude categoryOneOf?: number[] licenceOneOf?: number[] @@ -101,6 +107,7 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { getIdsListQueryAndSort (options: BuildVideosListQueryOptions) { this.buildIdsListQuery(options) + return { query: this.query, sort: this.sort, replacements: this.replacements } } @@ -116,23 +123,30 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"' ]) - this.whereNotBlacklisted() + if (!(options.include & VideoInclude.BLACKLISTED)) { + this.whereNotBlacklisted() + } - if (options.serverAccountId) { - this.whereNotBlocked(options.serverAccountId, options.user) + if (options.serverAccountIdForBlock && !(options.include & VideoInclude.BLOCKED_OWNER)) { + this.whereNotBlocked(options.serverAccountIdForBlock, options.user) } - // Only list public/published videos - if (!options.filter || (options.filter !== 'all-local' && options.filter !== 'all')) { - this.whereStateAndPrivacyAvailable(options.user) + // Only list published videos + if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) { + this.whereStateAvailable() + } + + // Only list videos with the appropriate priavcy + if (!(options.include & VideoInclude.HIDDEN_PRIVACY)) { + this.wherePrivacyAvailable(options.user) } if (options.videoPlaylistId) { this.joinPlaylist(options.videoPlaylistId) } - if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) { - this.whereOnlyLocal() + if (exists(options.isLocal)) { + this.whereLocal(options.isLocal) } if (options.host) { @@ -147,8 +161,8 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { this.whereChannelId(options.videoChannelId) } - if (options.followerActorId) { - this.whereFollowerActorId(options.followerActorId, options.includeLocalVideos) + if (options.displayOnlyForFollower) { + this.whereFollowerActorId(options.displayOnlyForFollower) } if (options.withFiles === true) { @@ -282,12 +296,14 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { this.replacements.videoPlaylistId = playlistId } - private whereStateAndPrivacyAvailable (user?: MUserAccountId) { + private whereStateAvailable () { this.and.push( `("video"."state" = ${VideoState.PUBLISHED} OR ` + `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))` ) + } + private wherePrivacyAvailable (user?: MUserAccountId) { if (user) { this.and.push( `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})` @@ -299,8 +315,10 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { } } - private whereOnlyLocal () { - this.and.push('"video"."remote" IS FALSE') + private whereLocal (isLocal: boolean) { + const isRemote = isLocal ? 'FALSE' : 'TRUE' + + this.and.push('"video"."remote" IS ' + isRemote) } private whereHost (host: string) { @@ -326,7 +344,7 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { this.replacements.videoChannelId = channelId } - private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) { + private whereFollowerActorId (options: { actorId: number, orLocalVideos: boolean }) { let query = '(' + ' EXISTS (' + // Videos shared by actors we follow @@ -342,14 +360,14 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder { ' AND "actorFollow"."state" = \'accepted\'' + ' )' - if (includeLocalVideos) { + if (options.orLocalVideos) { query += ' OR "video"."remote" IS FALSE' } query += ')' this.and.push(query) - this.replacements.followerActorId = followerActorId + this.replacements.followerActorId = options.actorId } private whereFileExists () { diff --git a/server/models/video/sql/videos-model-list-query-builder.ts b/server/models/video/sql/videos-model-list-query-builder.ts index e61c51de8..ef92bd2b0 100644 --- a/server/models/video/sql/videos-model-list-query-builder.ts +++ b/server/models/video/sql/videos-model-list-query-builder.ts @@ -1,3 +1,4 @@ +import { VideoInclude } from '@shared/models' import { Sequelize } from 'sequelize' import { AbstractVideosModelQueryBuilder } from './shared/abstract-videos-model-query-builder' import { VideoModelBuilder } from './shared/video-model-builder' @@ -28,7 +29,7 @@ export class VideosModelListQueryBuilder extends AbstractVideosModelQueryBuilder this.buildListQueryFromIdsQuery(options) return this.runQuery() - .then(rows => this.videoModelBuilder.buildVideosFromRows(rows)) + .then(rows => this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include })) } private buildInnerQuery (options: BuildVideosListQueryOptions) { @@ -64,6 +65,14 @@ export class VideosModelListQueryBuilder extends AbstractVideosModelQueryBuilder this.includePlaylist(options.videoPlaylistId) } + if (options.include & VideoInclude.BLACKLISTED) { + this.includeBlacklisted() + } + + if (options.include & VideoInclude.BLOCKED_OWNER) { + this.includeBlockedOwnerAndServer(options.serverAccountIdForBlock, options.user) + } + const select = this.buildSelect() this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}` diff --git a/server/models/video/video.ts b/server/models/video/video.ts index b5c46c86c..26be34329 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -34,12 +34,12 @@ import { VideoPathManager } from '@server/lib/video-path-manager' import { getServerActor } from '@server/models/application/application' import { ModelCache } from '@server/models/model-cache' import { AttributesOnly, buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' +import { VideoInclude } from '@shared/models' import { VideoFile } from '@shared/models/videos/video-file.model' import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared' import { VideoObject } from '../../../shared/models/activitypub/objects' import { Video, VideoDetails, VideoRateType, VideoStorage } from '../../../shared/models/videos' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' -import { VideoFilter } from '../../../shared/models/videos/video-query.type' import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' import { peertubeTruncate } from '../../helpers/core-utils' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' @@ -106,7 +106,7 @@ import { } from './formatter/video-format-utils' import { ScheduleVideoUpdateModel } from './schedule-video-update' import { VideosModelGetQueryBuilder } from './sql/video-model-get-query-builder' -import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder' +import { BuildVideosListQueryOptions, DisplayOnlyForFollowerOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder' import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder' import { TagModel } from './tag' import { ThumbnailModel } from './thumbnail' @@ -145,35 +145,6 @@ export type ForAPIOptions = { withAccountBlockerIds?: number[] } -export type AvailableForListIDsOptions = { - serverAccountId: number - followerActorId: number - includeLocalVideos: boolean - - attributesType?: 'none' | 'id' | 'all' - - filter?: VideoFilter - categoryOneOf?: number[] - nsfw?: boolean - licenceOneOf?: number[] - languageOneOf?: string[] - tagsOneOf?: string[] - tagsAllOf?: string[] - - withFiles?: boolean - - accountId?: number - videoChannelId?: number - - videoPlaylistId?: number - - trendingDays?: number - user?: MUserAccountId - historyOfUser?: MUserId - - baseWhere?: WhereOptions[] -} - @Scopes(() => ({ [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: { attributes: [ 'id', 'url', 'uuid', 'remote' ] @@ -1054,10 +1025,10 @@ export class VideoModel extends Model>> { sort: string nsfw: boolean - filter?: VideoFilter isLive?: boolean + isLocal?: boolean + include?: VideoInclude - includeLocalVideos: boolean withFiles: boolean categoryOneOf?: number[] @@ -1069,7 +1040,7 @@ export class VideoModel extends Model>> { accountId?: number videoChannelId?: number - followerActorId?: number + displayOnlyForFollower: DisplayOnlyForFollowerOptions | null videoPlaylistId?: number @@ -1082,7 +1053,7 @@ export class VideoModel extends Model>> { search?: string }) { - if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { + if (options.include && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { throw new Error('Try to filter all-local but no user has not the see all videos right') } @@ -1096,11 +1067,6 @@ export class VideoModel extends Model>> { const serverActor = await getServerActor() - // followerActorId === null has a meaning, so just check undefined - const followerActorId = options.followerActorId !== undefined - ? options.followerActorId - : serverActor.id - const queryOptions = { ...pick(options, [ 'start', @@ -1113,19 +1079,19 @@ export class VideoModel extends Model>> { 'languageOneOf', 'tagsOneOf', 'tagsAllOf', - 'filter', + 'isLocal', + 'include', + 'displayOnlyForFollower', 'withFiles', 'accountId', 'videoChannelId', 'videoPlaylistId', - 'includeLocalVideos', 'user', 'historyOfUser', 'search' ]), - followerActorId, - serverAccountId: serverActor.Account.id, + serverAccountIdForBlock: serverActor.Account.id, trendingDays, trendingAlgorithm } @@ -1137,7 +1103,6 @@ export class VideoModel extends Model>> { start: number count: number sort: string - includeLocalVideos: boolean search?: string host?: string startDate?: string // ISO 8601 @@ -1146,6 +1111,8 @@ export class VideoModel extends Model>> { originallyPublishedEndDate?: string nsfw?: boolean isLive?: boolean + isLocal?: boolean + include?: VideoInclude categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] @@ -1154,14 +1121,14 @@ export class VideoModel extends Model>> { durationMin?: number // seconds durationMax?: number // seconds user?: MUserAccountId - filter?: VideoFilter uuids?: string[] + displayOnlyForFollower: DisplayOnlyForFollowerOptions | null }) { const serverActor = await getServerActor() const queryOptions = { ...pick(options, [ - 'includeLocalVideos', + 'include', 'nsfw', 'isLive', 'categoryOneOf', @@ -1170,7 +1137,7 @@ export class VideoModel extends Model>> { 'tagsOneOf', 'tagsAllOf', 'user', - 'filter', + 'isLocal', 'host', 'start', 'count', @@ -1182,11 +1149,10 @@ export class VideoModel extends Model>> { 'durationMin', 'durationMax', 'uuids', - 'search' + 'search', + 'displayOnlyForFollower' ]), - - followerActorId: serverActor.id, - serverAccountId: serverActor.Account.id + serverAccountIdForBlock: serverActor.Account.id } return VideoModel.getAvailableForApi(queryOptions) @@ -1369,12 +1335,17 @@ export class VideoModel extends Model>> { // Sequelize could return null... if (!totalLocalVideoViews) totalLocalVideoViews = 0 + const serverActor = await getServerActor() + const { total: totalVideos } = await VideoModel.listForApi({ start: 0, count: 0, sort: '-publishedAt', nsfw: buildNSFWFilter(), - includeLocalVideos: true, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + }, withFiles: false }) @@ -1455,7 +1426,6 @@ export class VideoModel extends Model>> { // threshold corresponds to how many video the field should have to be returned static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { const serverActor = await getServerActor() - const followerActorId = serverActor.id const queryOptions: BuildVideosListQueryOptions = { attributes: [ `"${field}"` ], @@ -1464,9 +1434,11 @@ export class VideoModel extends Model>> { start: 0, sort: 'random', count, - serverAccountId: serverActor.Account.id, - followerActorId, - includeLocalVideos: true + serverAccountIdForBlock: serverActor.Account.id, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + } } const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize) diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index a14e4d3e0..0882f8176 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -27,6 +27,6 @@ import './video-comments' import './video-imports' import './video-playlists' import './videos' -import './videos-filter' +import './videos-common-filters' import './videos-history' import './videos-overviews' diff --git a/server/tests/api/check-params/videos-common-filters.ts b/server/tests/api/check-params/videos-common-filters.ts new file mode 100644 index 000000000..afe42b0d5 --- /dev/null +++ b/server/tests/api/check-params/videos-common-filters.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@shared/extra-utils' +import { HttpStatusCode, UserRole, VideoInclude } from '@shared/models' + +describe('Test video filters validators', function () { + let server: PeerTubeServer + let userAccessToken: string + let moderatorAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + const user = { username: 'user1', password: 'my super password' } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + + const moderator = { username: 'moderator', password: 'my super password' } + await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) + + moderatorAccessToken = await server.login.getAccessToken(moderator) + }) + + describe('When setting a deprecated video filter', function () { + + async function testEndpoints (token: string, filter: string, expectedStatus: HttpStatusCode) { + const paths = [ + '/api/v1/video-channels/root_channel/videos', + '/api/v1/accounts/root/videos', + '/api/v1/videos', + '/api/v1/search/videos' + ] + + for (const path of paths) { + await makeGetRequest({ + url: server.url, + path, + token, + query: { + filter + }, + expectedStatus + }) + } + } + + it('Should fail with a bad filter', async function () { + await testEndpoints(server.accessToken, 'bad-filter', HttpStatusCode.BAD_REQUEST_400) + }) + + it('Should succeed with a good filter', async function () { + await testEndpoints(server.accessToken, 'local', HttpStatusCode.OK_200) + }) + + it('Should fail to list all-local/all with a simple user', async function () { + await testEndpoints(userAccessToken, 'all-local', HttpStatusCode.UNAUTHORIZED_401) + await testEndpoints(userAccessToken, 'all', HttpStatusCode.UNAUTHORIZED_401) + }) + + it('Should succeed to list all-local/all with a moderator', async function () { + await testEndpoints(moderatorAccessToken, 'all-local', HttpStatusCode.OK_200) + await testEndpoints(moderatorAccessToken, 'all', HttpStatusCode.OK_200) + }) + + it('Should succeed to list all-local/all with an admin', async function () { + await testEndpoints(server.accessToken, 'all-local', HttpStatusCode.OK_200) + await testEndpoints(server.accessToken, 'all', HttpStatusCode.OK_200) + }) + + // Because we cannot authenticate the user on the RSS endpoint + it('Should fail on the feeds endpoint with the all-local/all filter', async function () { + for (const filter of [ 'all', 'all-local' ]) { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + query: { + filter + } + }) + } + }) + + it('Should succeed on the feeds endpoint with the local filter', async function () { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.OK_200, + query: { + filter: 'local' + } + }) + }) + }) + + describe('When setting video filters', function () { + + const validIncludes = [ + VideoInclude.NONE, + VideoInclude.HIDDEN_PRIVACY, + VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED + ] + + async function testEndpoints (options: { + token?: string + isLocal?: boolean + include?: VideoInclude + expectedStatus: HttpStatusCode + }) { + const paths = [ + '/api/v1/video-channels/root_channel/videos', + '/api/v1/accounts/root/videos', + '/api/v1/videos', + '/api/v1/search/videos' + ] + + for (const path of paths) { + await makeGetRequest({ + url: server.url, + path, + token: options.token || server.accessToken, + query: { + isLocal: options.isLocal, + include: options.include + }, + expectedStatus: options.expectedStatus + }) + } + } + + it('Should fail with a bad include', async function () { + await testEndpoints({ include: 'toto' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a good include', async function () { + for (const include of validIncludes) { + await testEndpoints({ include, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should fail to include more videos with a simple user', async function () { + for (const include of validIncludes) { + await testEndpoints({ token: userAccessToken, include, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should succeed to list all local/all with a moderator', async function () { + for (const include of validIncludes) { + await testEndpoints({ token: moderatorAccessToken, include, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should succeed to list all local/all with an admin', async function () { + for (const include of validIncludes) { + await testEndpoints({ token: server.accessToken, include, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + // Because we cannot authenticate the user on the RSS endpoint + it('Should fail on the feeds endpoint with the all filter', async function () { + for (const include of [ VideoInclude.NOT_PUBLISHED_STATE ]) { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + query: { + include + } + }) + } + }) + + it('Should succeed on the feeds endpoint with the local filter', async function () { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.OK_200, + query: { + isLocal: true + } + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/check-params/videos-filter.ts b/server/tests/api/check-params/videos-filter.ts deleted file mode 100644 index d08570bbe..000000000 --- a/server/tests/api/check-params/videos-filter.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ - -import 'mocha' -import { - cleanupTests, - createSingleServer, - makeGetRequest, - PeerTubeServer, - setAccessTokensToServers, - setDefaultVideoChannel -} from '@shared/extra-utils' -import { HttpStatusCode, UserRole } from '@shared/models' - -async function testEndpoints (server: PeerTubeServer, token: string, filter: string, expectedStatus: HttpStatusCode) { - const paths = [ - '/api/v1/video-channels/root_channel/videos', - '/api/v1/accounts/root/videos', - '/api/v1/videos', - '/api/v1/search/videos' - ] - - for (const path of paths) { - await makeGetRequest({ - url: server.url, - path, - token, - query: { - filter - }, - expectedStatus - }) - } -} - -describe('Test video filters validators', function () { - let server: PeerTubeServer - let userAccessToken: string - let moderatorAccessToken: string - - // --------------------------------------------------------------- - - before(async function () { - this.timeout(30000) - - server = await createSingleServer(1) - - await setAccessTokensToServers([ server ]) - await setDefaultVideoChannel([ server ]) - - const user = { username: 'user1', password: 'my super password' } - await server.users.create({ username: user.username, password: user.password }) - userAccessToken = await server.login.getAccessToken(user) - - const moderator = { username: 'moderator', password: 'my super password' } - await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) - - moderatorAccessToken = await server.login.getAccessToken(moderator) - }) - - describe('When setting a video filter', function () { - - it('Should fail with a bad filter', async function () { - await testEndpoints(server, server.accessToken, 'bad-filter', HttpStatusCode.BAD_REQUEST_400) - }) - - it('Should succeed with a good filter', async function () { - await testEndpoints(server, server.accessToken, 'local', HttpStatusCode.OK_200) - }) - - it('Should fail to list all-local/all with a simple user', async function () { - await testEndpoints(server, userAccessToken, 'all-local', HttpStatusCode.UNAUTHORIZED_401) - await testEndpoints(server, userAccessToken, 'all', HttpStatusCode.UNAUTHORIZED_401) - }) - - it('Should succeed to list all-local/all with a moderator', async function () { - await testEndpoints(server, moderatorAccessToken, 'all-local', HttpStatusCode.OK_200) - await testEndpoints(server, moderatorAccessToken, 'all', HttpStatusCode.OK_200) - }) - - it('Should succeed to list all-local/all with an admin', async function () { - await testEndpoints(server, server.accessToken, 'all-local', HttpStatusCode.OK_200) - await testEndpoints(server, server.accessToken, 'all', HttpStatusCode.OK_200) - }) - - // Because we cannot authenticate the user on the RSS endpoint - it('Should fail on the feeds endpoint with the all-local/all filter', async function () { - for (const filter of [ 'all', 'all-local' ]) { - await makeGetRequest({ - url: server.url, - path: '/feeds/videos.json', - expectedStatus: HttpStatusCode.UNAUTHORIZED_401, - query: { - filter - } - }) - } - }) - - it('Should succeed on the feeds endpoint with the local filter', async function () { - await makeGetRequest({ - url: server.url, - path: '/feeds/videos.json', - expectedStatus: HttpStatusCode.OK_200, - query: { - filter: 'local' - } - }) - }) - }) - - after(async function () { - await cleanupTests([ server ]) - }) -}) diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index 5c07f8926..c9c678e9d 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -15,7 +15,7 @@ import './video-playlist-thumbnails' import './video-privacy' import './video-schedule-update' import './video-transcoder' -import './videos-filter' +import './videos-common-filters' import './videos-history' import './videos-overview' import './videos-views-cleaner' diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts index df9deb1e1..9c255c1c5 100644 --- a/server/tests/api/videos/multiple-servers.ts +++ b/server/tests/api/videos/multiple-servers.ts @@ -349,7 +349,7 @@ describe('Test multiple servers', function () { describe('It should list local videos', function () { it('Should list only local videos on server 1', async function () { - const { data, total } = await servers[0].videos.list({ filter: 'local' }) + const { data, total } = await servers[0].videos.list({ isLocal: true }) expect(total).to.equal(1) expect(data).to.be.an('array') @@ -358,7 +358,7 @@ describe('Test multiple servers', function () { }) it('Should list only local videos on server 2', async function () { - const { data, total } = await servers[1].videos.list({ filter: 'local' }) + const { data, total } = await servers[1].videos.list({ isLocal: true }) expect(total).to.equal(1) expect(data).to.be.an('array') @@ -367,7 +367,7 @@ describe('Test multiple servers', function () { }) it('Should list only local videos on server 3', async function () { - const { data, total } = await servers[2].videos.list({ filter: 'local' }) + const { data, total } = await servers[2].videos.list({ isLocal: true }) expect(total).to.equal(2) expect(data).to.be.an('array') diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts index 29dac6ec1..a0e4a156c 100644 --- a/server/tests/api/videos/single-server.ts +++ b/server/tests/api/videos/single-server.ts @@ -354,19 +354,6 @@ describe('Test a single server', function () { await server.videos.update({ id: videoId, attributes }) }) - it('Should filter by tags and category', async function () { - { - const { data, total } = await server.videos.list({ tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 4 ] }) - expect(total).to.equal(1) - expect(data[0].name).to.equal('my super video updated') - } - - { - const { total } = await server.videos.list({ tagsAllOf: [ 'tagup1', 'tagup2' ], categoryOneOf: [ 3 ] }) - expect(total).to.equal(0) - } - }) - it('Should have the video updated', async function () { this.timeout(60000) diff --git a/server/tests/api/videos/videos-common-filters.ts b/server/tests/api/videos/videos-common-filters.ts new file mode 100644 index 000000000..eb2d2ab50 --- /dev/null +++ b/server/tests/api/videos/videos-common-filters.ts @@ -0,0 +1,403 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { expect } from 'chai' +import { pick } from '@shared/core-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@shared/extra-utils' +import { HttpStatusCode, UserRole, Video, VideoInclude, VideoPrivacy } from '@shared/models' + +describe('Test videos filter', function () { + let servers: PeerTubeServer[] + let paths: string[] + let remotePaths: string[] + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(160000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + const moderator = { username: 'moderator', password: 'my super password' } + await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) + server['moderatorAccessToken'] = await server.login.getAccessToken(moderator) + + await server.videos.upload({ attributes: { name: 'public ' + server.serverNumber } }) + + { + const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED } + await server.videos.upload({ attributes }) + } + + { + const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE } + await server.videos.upload({ attributes }) + } + } + + await doubleFollow(servers[0], servers[1]) + + paths = [ + `/api/v1/video-channels/root_channel/videos`, + `/api/v1/accounts/root/videos`, + '/api/v1/videos', + '/api/v1/search/videos' + ] + + remotePaths = [ + `/api/v1/video-channels/root_channel@${servers[1].host}/videos`, + `/api/v1/accounts/root@${servers[1].host}/videos`, + '/api/v1/videos', + '/api/v1/search/videos' + ] + }) + + describe('Check deprecated videos filter', function () { + + async function getVideosNames (server: PeerTubeServer, token: string, filter: string, expectedStatus = HttpStatusCode.OK_200) { + const videosResults: Video[][] = [] + + for (const path of paths) { + const res = await makeGetRequest({ + url: server.url, + path, + token, + query: { + sort: 'createdAt', + filter + }, + expectedStatus + }) + + videosResults.push(res.body.data.map(v => v.name)) + } + + return videosResults + } + + it('Should display local videos', async function () { + for (const server of servers) { + const namesResults = await getVideosNames(server, server.accessToken, 'local') + for (const names of namesResults) { + expect(names).to.have.lengthOf(1) + expect(names[0]).to.equal('public ' + server.serverNumber) + } + } + }) + + it('Should display all local videos by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const namesResults = await getVideosNames(server, token, 'all-local') + for (const names of namesResults) { + expect(names).to.have.lengthOf(3) + + expect(names[0]).to.equal('public ' + server.serverNumber) + expect(names[1]).to.equal('unlisted ' + server.serverNumber) + expect(names[2]).to.equal('private ' + server.serverNumber) + } + } + } + }) + + it('Should display all videos by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames(server, token, 'all') + expect(channelVideos).to.have.lengthOf(3) + expect(accountVideos).to.have.lengthOf(3) + + expect(videos).to.have.lengthOf(5) + expect(searchVideos).to.have.lengthOf(5) + } + } + }) + }) + + describe('Check videos filters', function () { + + async function listVideos (options: { + server: PeerTubeServer + path: string + isLocal?: boolean + include?: VideoInclude + category?: number + tagsAllOf?: string[] + token?: string + expectedStatus?: HttpStatusCode + }) { + const res = await makeGetRequest({ + url: options.server.url, + path: options.path, + token: options.token ?? options.server.accessToken, + query: { + ...pick(options, [ 'isLocal', 'include', 'category', 'tagsAllOf' ]), + + sort: 'createdAt' + }, + expectedStatus: options.expectedStatus ?? HttpStatusCode.OK_200 + }) + + return res.body.data as Video[] + } + + async function getVideosNames (options: { + server: PeerTubeServer + isLocal?: boolean + include?: VideoInclude + token?: string + expectedStatus?: HttpStatusCode + }) { + const videosResults: string[][] = [] + + for (const path of paths) { + const videos = await listVideos({ ...options, path }) + + videosResults.push(videos.map(v => v.name)) + } + + return videosResults + } + + it('Should display local videos', async function () { + for (const server of servers) { + const namesResults = await getVideosNames({ server, isLocal: true }) + + for (const names of namesResults) { + expect(names).to.have.lengthOf(1) + expect(names[0]).to.equal('public ' + server.serverNumber) + } + } + }) + + it('Should display local videos with hidden privacy by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const namesResults = await getVideosNames({ + server, + token, + isLocal: true, + include: VideoInclude.HIDDEN_PRIVACY + }) + + for (const names of namesResults) { + expect(names).to.have.lengthOf(3) + + expect(names[0]).to.equal('public ' + server.serverNumber) + expect(names[1]).to.equal('unlisted ' + server.serverNumber) + expect(names[2]).to.equal('private ' + server.serverNumber) + } + } + } + }) + + it('Should display all videos by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames({ + server, + token, + include: VideoInclude.HIDDEN_PRIVACY + }) + + expect(channelVideos).to.have.lengthOf(3) + expect(accountVideos).to.have.lengthOf(3) + + expect(videos).to.have.lengthOf(5) + expect(searchVideos).to.have.lengthOf(5) + } + } + }) + + it('Should display only remote videos', async function () { + this.timeout(40000) + + await servers[1].videos.upload({ attributes: { name: 'remote video' } }) + + await waitJobs(servers) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.exist + } + + { + const videos = await listVideos({ server: servers[0], path, isLocal: false }) + const video = finder(videos) + expect(video).to.exist + } + + { + const videos = await listVideos({ server: servers[0], path, isLocal: true }) + const video = finder(videos) + expect(video).to.not.exist + } + } + }) + + it('Should include not published videos', async function () { + await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) + await servers[0].live.create({ fields: { name: 'live video', channelId: servers[0].store.channel.id, privacy: VideoPrivacy.PUBLIC } }) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'live video') + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + expect(videos[0].state).to.not.exist + expect(videos[0].waitTranscoding).to.not.exist + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.NOT_PUBLISHED_STATE }) + const video = finder(videos) + expect(video).to.exist + expect(video.state).to.exist + } + } + }) + + it('Should include blacklisted videos', async function () { + const { id } = await servers[0].videos.upload({ attributes: { name: 'blacklisted' } }) + + await servers[0].blacklist.add({ videoId: id }) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'blacklisted') + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + expect(videos[0].blacklisted).to.not.exist + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLACKLISTED }) + const video = finder(videos) + expect(video).to.exist + expect(video.blacklisted).to.be.true + } + } + }) + + it('Should include videos from muted account', async function () { + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + await servers[0].blocklist.addToServerBlocklist({ account: 'root@' + servers[1].host }) + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + + // Some paths won't have videos + if (videos[0]) { + expect(videos[0].blockedOwner).to.not.exist + expect(videos[0].blockedServer).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLOCKED_OWNER }) + + const video = finder(videos) + expect(video).to.exist + expect(video.blockedServer).to.be.false + expect(video.blockedOwner).to.be.true + } + } + + await servers[0].blocklist.removeFromServerBlocklist({ account: 'root@' + servers[1].host }) + }) + + it('Should include videos from muted server', async function () { + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + await servers[0].blocklist.addToServerBlocklist({ server: servers[1].host }) + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + + // Some paths won't have videos + if (videos[0]) { + expect(videos[0].blockedOwner).to.not.exist + expect(videos[0].blockedServer).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLOCKED_OWNER }) + const video = finder(videos) + expect(video).to.exist + expect(video.blockedServer).to.be.true + expect(video.blockedOwner).to.be.false + } + } + + await servers[0].blocklist.removeFromServerBlocklist({ server: servers[1].host }) + }) + + it('Should filter by tags and category', async function () { + await servers[0].videos.upload({ attributes: { name: 'tag filter', tags: [ 'tag1', 'tag2' ] } }) + await servers[0].videos.upload({ attributes: { name: 'tag filter with category', tags: [ 'tag3' ], category: 4 } }) + + for (const path of paths) { + { + + const videos = await listVideos({ server: servers[0], path, tagsAllOf: [ 'tag1', 'tag2' ] }) + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('tag filter') + + } + + { + const videos = await listVideos({ server: servers[0], path, tagsAllOf: [ 'tag1', 'tag3' ] }) + expect(videos).to.have.lengthOf(0) + } + + { + const { data, total } = await servers[0].videos.list({ tagsAllOf: [ 'tag3' ], categoryOneOf: [ 4 ] }) + expect(total).to.equal(1) + expect(data[0].name).to.equal('tag filter with category') + } + + { + const { total } = await servers[0].videos.list({ tagsAllOf: [ 'tag4' ], categoryOneOf: [ 4 ] }) + expect(total).to.equal(0) + } + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/server/tests/api/videos/videos-filter.ts b/server/tests/api/videos/videos-filter.ts deleted file mode 100644 index 2306807bf..000000000 --- a/server/tests/api/videos/videos-filter.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ - -import 'mocha' -import { expect } from 'chai' -import { - cleanupTests, - createMultipleServers, - doubleFollow, - makeGetRequest, - PeerTubeServer, - setAccessTokensToServers -} from '@shared/extra-utils' -import { HttpStatusCode, UserRole, Video, VideoPrivacy } from '@shared/models' - -async function getVideosNames (server: PeerTubeServer, token: string, filter: string, expectedStatus = HttpStatusCode.OK_200) { - const paths = [ - '/api/v1/video-channels/root_channel/videos', - '/api/v1/accounts/root/videos', - '/api/v1/videos', - '/api/v1/search/videos' - ] - - const videosResults: Video[][] = [] - - for (const path of paths) { - const res = await makeGetRequest({ - url: server.url, - path, - token, - query: { - sort: 'createdAt', - filter - }, - expectedStatus - }) - - videosResults.push(res.body.data.map(v => v.name)) - } - - return videosResults -} - -describe('Test videos filter', function () { - let servers: PeerTubeServer[] - - // --------------------------------------------------------------- - - before(async function () { - this.timeout(160000) - - servers = await createMultipleServers(2) - - await setAccessTokensToServers(servers) - - for (const server of servers) { - const moderator = { username: 'moderator', password: 'my super password' } - await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) - server['moderatorAccessToken'] = await server.login.getAccessToken(moderator) - - await server.videos.upload({ attributes: { name: 'public ' + server.serverNumber } }) - - { - const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED } - await server.videos.upload({ attributes }) - } - - { - const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE } - await server.videos.upload({ attributes }) - } - } - - await doubleFollow(servers[0], servers[1]) - }) - - describe('Check videos filter', function () { - - it('Should display local videos', async function () { - for (const server of servers) { - const namesResults = await getVideosNames(server, server.accessToken, 'local') - for (const names of namesResults) { - expect(names).to.have.lengthOf(1) - expect(names[0]).to.equal('public ' + server.serverNumber) - } - } - }) - - it('Should display all local videos by the admin or the moderator', async function () { - for (const server of servers) { - for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { - - const namesResults = await getVideosNames(server, token, 'all-local') - for (const names of namesResults) { - expect(names).to.have.lengthOf(3) - - expect(names[0]).to.equal('public ' + server.serverNumber) - expect(names[1]).to.equal('unlisted ' + server.serverNumber) - expect(names[2]).to.equal('private ' + server.serverNumber) - } - } - } - }) - - it('Should display all videos by the admin or the moderator', async function () { - for (const server of servers) { - for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { - - const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames(server, token, 'all') - expect(channelVideos).to.have.lengthOf(3) - expect(accountVideos).to.have.lengthOf(3) - - expect(videos).to.have.lengthOf(5) - expect(searchVideos).to.have.lengthOf(5) - } - } - }) - }) - - after(async function () { - await cleanupTests(servers) - }) -}) diff --git a/server/types/models/account/account.ts b/server/types/models/account/account.ts index 984841291..abe0de27b 100644 --- a/server/types/models/account/account.ts +++ b/server/types/models/account/account.ts @@ -84,7 +84,7 @@ export type MAccountSummary = export type MAccountSummaryBlocks = MAccountSummary & - Use<'BlockedAccounts', MAccountBlocklistId[]> + Use<'BlockedByAccounts', MAccountBlocklistId[]> export type MAccountAPI = MAccount & diff --git a/shared/extra-utils/videos/videos-command.ts b/shared/extra-utils/videos/videos-command.ts index c1a9ec806..68241f062 100644 --- a/shared/extra-utils/videos/videos-command.ts +++ b/shared/extra-utils/videos/videos-command.ts @@ -18,8 +18,7 @@ import { VideoDetails, VideoFileMetadata, VideoPrivacy, - VideosCommonQuery, - VideosWithSearchCommonQuery + VideosCommonQuery } from '@shared/models' import { buildAbsoluteFixturePath, wait } from '../miscs' import { unwrapBody } from '../requests' @@ -246,7 +245,7 @@ export class VideosCommand extends AbstractCommand { }) } - listByAccount (options: OverrideCommandOptions & VideosWithSearchCommonQuery & { + listByAccount (options: OverrideCommandOptions & VideosCommonQuery & { handle: string }) { const { handle, search } = options @@ -262,7 +261,7 @@ export class VideosCommand extends AbstractCommand { }) } - listByChannel (options: OverrideCommandOptions & VideosWithSearchCommonQuery & { + listByChannel (options: OverrideCommandOptions & VideosCommonQuery & { handle: string }) { const { handle } = options @@ -605,7 +604,8 @@ export class VideosCommand extends AbstractCommand { 'languageOneOf', 'tagsOneOf', 'tagsAllOf', - 'filter', + 'isLocal', + 'include', 'skipCount' ]) } diff --git a/shared/models/search/videos-common-query.model.ts b/shared/models/search/videos-common-query.model.ts index 2f2e9a934..55a98e302 100644 --- a/shared/models/search/videos-common-query.model.ts +++ b/shared/models/search/videos-common-query.model.ts @@ -1,4 +1,4 @@ -import { VideoFilter } from '../videos' +import { VideoInclude } from '../videos/video-include.enum' import { BooleanBothQuery } from './boolean-both-query.model' // These query parameters can be used with any endpoint that list videos @@ -11,6 +11,12 @@ export interface VideosCommonQuery { isLive?: boolean + // FIXME: deprecated in 4.0 in favour of isLocal and include, to remove + filter?: never + + isLocal?: boolean + include?: VideoInclude + categoryOneOf?: number[] licenceOneOf?: number[] @@ -20,17 +26,16 @@ export interface VideosCommonQuery { tagsOneOf?: string[] tagsAllOf?: string[] - filter?: VideoFilter - skipCount?: boolean + + search?: string } export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery { start: number count: number sort: string -} -export interface VideosWithSearchCommonQuery extends VideosCommonQuery { - search?: string + // FIXME: deprecated in 4.0, to remove + filter?: never } diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts index a5436879d..447c72806 100644 --- a/shared/models/search/videos-search-query.model.ts +++ b/shared/models/search/videos-search-query.model.ts @@ -23,4 +23,7 @@ export interface VideosSearchQueryAfterSanitize extends VideosSearchQuery { start: number count: number sort: string + + // FIXME: deprecated in 4.0, to remove + filter?: never } diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 733c433a0..3d3eedcc6 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -19,7 +19,8 @@ export * from './video-file-metadata.model' export * from './video-file.model' export * from './video-privacy.enum' -export * from './video-query.type' +export * from './video-filter.type' +export * from './video-include.enum' export * from './video-rate.type' export * from './video-resolution.enum' diff --git a/shared/models/videos/video-query.type.ts b/shared/models/videos/video-filter.type.ts similarity index 100% rename from shared/models/videos/video-query.type.ts rename to shared/models/videos/video-filter.type.ts diff --git a/shared/models/videos/video-include.enum.ts b/shared/models/videos/video-include.enum.ts new file mode 100644 index 000000000..fa720b348 --- /dev/null +++ b/shared/models/videos/video-include.enum.ts @@ -0,0 +1,7 @@ +export const enum VideoInclude { + NONE = 0, + NOT_PUBLISHED_STATE = 1 << 0, + HIDDEN_PRIVACY = 1 << 1, + BLACKLISTED = 1 << 2, + BLOCKED_OWNER = 1 << 3 +} diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index 4a7e399a2..dadde38af 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -43,13 +43,6 @@ export interface Video { dislikes: number nsfw: boolean - waitTranscoding?: boolean - state?: VideoConstant - scheduledUpdate?: VideoScheduleUpdate - - blacklisted?: boolean - blacklistedReason?: string - account: AccountSummary channel: VideoChannelSummary @@ -58,6 +51,17 @@ export interface Video { } pluginData?: any + + // Additional attributes dependending on the query + waitTranscoding?: boolean + state?: VideoConstant + scheduledUpdate?: VideoScheduleUpdate + + blacklisted?: boolean + blacklistedReason?: string + + blockedOwner?: boolean + blockedServer?: boolean } export interface VideoDetails extends Video { @@ -70,7 +74,7 @@ export interface VideoDetails extends Video { commentsEnabled: boolean downloadEnabled: boolean - // Not optional in details (unlike in Video) + // Not optional in details (unlike in parent Video) waitTranscoding: boolean state: VideoConstant diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index ef4e7d04d..cdb4dd343 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -367,7 +367,8 @@ paths: - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' - $ref: '#/components/parameters/skipCount' - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' @@ -1300,7 +1301,8 @@ paths: - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' - $ref: '#/components/parameters/skipCount' - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' @@ -1620,7 +1622,8 @@ paths: - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' - $ref: '#/components/parameters/skipCount' - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' @@ -2856,7 +2859,8 @@ paths: - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' - $ref: '#/components/parameters/skipCount' - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' @@ -3576,7 +3580,8 @@ paths: - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' - $ref: '#/components/parameters/skipCount' - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' @@ -4078,7 +4083,8 @@ paths: type: string - $ref: '#/components/parameters/sort' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' responses: '204': description: successful operation @@ -4159,7 +4165,8 @@ paths: required: true - $ref: '#/components/parameters/sort' - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/filter' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' responses: '204': description: successful operation @@ -4792,20 +4799,37 @@ components: enum: - 'true' - 'false' - filter: - name: filter + isLocal: + name: isLocal in: query required: false - description: > - Special filters which might require special rights: - * `local` - only videos local to the instance - * `all-local` - only videos local to the instance, but showing private and unlisted videos (requires Admin privileges) - * `all` - all videos, showing private and unlisted videos (requires Admin privileges) schema: - type: string + type: boolean + description: 'Display only local or remote videos' + include: + name: include + in: query + required: false + schema: + type: integer enum: - - local - - all-local + - 0 + - 1 + - 2 + - 4 + - 8 + description: > + Include additional videos in results (can be combined using bitwise or operator) + + - `0` NONE + + - `1` NOT_PUBLISHED_STATE + + - `2` HIDDEN_PRIVACY + + - `4` BLACKLISTED + + - `8` BLOCKED subscriptionsUris: name: uris in: query @@ -6995,7 +7019,7 @@ components: enum: - 0 - 1 - - 3 + - 2 Notification: properties: id: -- 2.41.0