From 37a44fc915eef2140e22ceb96aba6b6eb2509007 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 17 Jun 2021 16:02:38 +0200 Subject: Add ability to search playlists --- server/controllers/activitypub/client.ts | 3 +- server/controllers/api/search.ts | 294 --------------------- server/controllers/api/search/index.ts | 16 ++ .../api/search/search-video-channels.ts | 150 +++++++++++ .../api/search/search-video-playlists.ts | 129 +++++++++ server/controllers/api/search/search-videos.ts | 153 +++++++++++ server/controllers/api/video-channel.ts | 4 +- server/controllers/api/video-playlist.ts | 8 +- .../custom-validators/activitypub/playlist.ts | 7 +- server/initializers/constants.ts | 1 + server/lib/activitypub/actors/get.ts | 2 +- server/lib/activitypub/playlists/create-update.ts | 34 ++- server/lib/activitypub/playlists/get.ts | 35 +++ server/lib/activitypub/playlists/index.ts | 1 + server/lib/activitypub/playlists/refresh.ts | 13 +- .../playlists/shared/object-to-model-attributes.ts | 6 +- server/lib/activitypub/process/process-create.ts | 2 +- server/lib/activitypub/process/process-update.ts | 2 +- server/lib/activitypub/videos/get.ts | 7 +- .../job-queue/handlers/activitypub-http-fetcher.ts | 8 +- server/lib/search.ts | 50 ++++ server/middlewares/validators/search.ts | 9 +- server/middlewares/validators/sort.ts | 3 + .../validators/videos/video-channels.ts | 13 + .../sql/shared/abstract-videos-query-builder.ts | 2 +- server/models/video/video-channel.ts | 4 +- server/models/video/video-playlist.ts | 84 +++++- server/tests/api/check-params/search.ts | 25 ++ server/tests/api/search/index.ts | 4 +- .../search/search-activitypub-video-channels.ts | 16 ++ .../search/search-activitypub-video-playlists.ts | 212 +++++++++++++++ .../tests/api/search/search-activitypub-videos.ts | 35 ++- server/tests/api/search/search-index.ts | 62 ++++- server/tests/api/search/search-playlists.ts | 128 +++++++++ .../languages/fr.json | 3 + .../languages/it.json | 3 + .../main.js | 21 ++ .../package.json | 23 ++ .../peertube-plugin-test-two/languages/fr.json | 3 - .../peertube-plugin-test-two/languages/it.json | 3 - .../fixtures/peertube-plugin-test-two/main.js | 21 -- .../fixtures/peertube-plugin-test-two/package.json | 23 -- server/tests/fixtures/peertube-plugin-test/main.js | 4 + server/tests/plugins/filter-hooks.ts | 24 +- server/tests/plugins/translations.ts | 8 +- server/types/models/video/video-playlist.ts | 4 +- 46 files changed, 1241 insertions(+), 421 deletions(-) delete mode 100644 server/controllers/api/search.ts create mode 100644 server/controllers/api/search/index.ts create mode 100644 server/controllers/api/search/search-video-channels.ts create mode 100644 server/controllers/api/search/search-video-playlists.ts create mode 100644 server/controllers/api/search/search-videos.ts create mode 100644 server/lib/activitypub/playlists/get.ts create mode 100644 server/lib/search.ts create mode 100644 server/tests/api/search/search-activitypub-video-playlists.ts create mode 100644 server/tests/api/search/search-playlists.ts create mode 100644 server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json create mode 100644 server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json create mode 100644 server/tests/fixtures/peertube-plugin-test-filter-translations/main.js create mode 100644 server/tests/fixtures/peertube-plugin-test-filter-translations/package.json delete mode 100644 server/tests/fixtures/peertube-plugin-test-two/languages/fr.json delete mode 100644 server/tests/fixtures/peertube-plugin-test-two/languages/it.json delete mode 100644 server/tests/fixtures/peertube-plugin-test-two/main.js delete mode 100644 server/tests/fixtures/peertube-plugin-test-two/package.json (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index bf8e3160b..d7de1b9bd 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -155,7 +155,8 @@ activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistT asyncMiddleware(videoRedundancyController) ) -activityPubClientRouter.get('/video-playlists/:playlistId', +activityPubClientRouter.get( + [ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ], executeIfActivityPub, asyncMiddleware(videoPlaylistsGetValidator('all')), asyncMiddleware(videoPlaylistController) diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts deleted file mode 100644 index c975c5c3c..000000000 --- a/server/controllers/api/search.ts +++ /dev/null @@ -1,294 +0,0 @@ -import * as express from 'express' -import { sanitizeUrl } from '@server/helpers/core-utils' -import { doJSONRequest } from '@server/helpers/requests' -import { CONFIG } from '@server/initializers/config' -import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' -import { Hooks } from '@server/lib/plugins/hooks' -import { AccountBlocklistModel } from '@server/models/account/account-blocklist' -import { getServerActor } from '@server/models/application/application' -import { ServerBlocklistModel } from '@server/models/server/server-blocklist' -import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' -import { ResultList, Video, VideoChannel } from '@shared/models' -import { SearchTargetQuery } from '@shared/models/search/search-target-query.model' -import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' -import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' -import { logger } from '../../helpers/logger' -import { getFormattedObjects } from '../../helpers/utils' -import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../lib/activitypub/actors' -import { - asyncMiddleware, - commonVideosFiltersValidator, - openapiOperationDoc, - optionalAuthenticate, - paginationValidator, - setDefaultPagination, - setDefaultSearchSort, - videoChannelsListSearchValidator, - videoChannelsSearchSortValidator, - videosSearchSortValidator, - videosSearchValidator -} from '../../middlewares' -import { VideoModel } from '../../models/video/video' -import { VideoChannelModel } from '../../models/video/video-channel' -import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../types/models' - -const searchRouter = express.Router() - -searchRouter.get('/videos', - openapiOperationDoc({ operationId: 'searchVideos' }), - paginationValidator, - setDefaultPagination, - videosSearchSortValidator, - setDefaultSearchSort, - optionalAuthenticate, - commonVideosFiltersValidator, - videosSearchValidator, - asyncMiddleware(searchVideos) -) - -searchRouter.get('/video-channels', - openapiOperationDoc({ operationId: 'searchChannels' }), - paginationValidator, - setDefaultPagination, - videoChannelsSearchSortValidator, - setDefaultSearchSort, - optionalAuthenticate, - videoChannelsListSearchValidator, - asyncMiddleware(searchVideoChannels) -) - -// --------------------------------------------------------------------------- - -export { searchRouter } - -// --------------------------------------------------------------------------- - -function searchVideoChannels (req: express.Request, res: express.Response) { - const query: VideoChannelsSearchQuery = req.query - const search = query.search - - const isURISearch = search.startsWith('http://') || search.startsWith('https://') - - const parts = search.split('@') - - // Handle strings like @toto@example.com - if (parts.length === 3 && parts[0].length === 0) parts.shift() - const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' ')) - - if (isURISearch || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res) - - // @username -> username to search in DB - if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '') - - if (isSearchIndexSearch(query)) { - return searchVideoChannelsIndex(query, res) - } - - return searchVideoChannelsDB(query, res) -} - -async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) { - const result = await buildMutedForSearchIndex(res) - - const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params') - - const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels' - - try { - logger.debug('Doing video channels search index request on %s.', url, { body }) - - const { body: searchIndexResult } = await doJSONRequest>(url, { method: 'POST', json: body }) - const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result') - - return res.json(jsonResult) - } catch (err) { - logger.warn('Cannot use search index to make video channels search.', { err }) - - return res.fail({ - status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, - message: 'Cannot use search index to make video channels search' - }) - } -} - -async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { - const serverActor = await getServerActor() - - const apiOptions = await Hooks.wrapObject({ - actorId: serverActor.id, - search: query.search, - start: query.start, - count: query.count, - sort: query.sort - }, 'filter:api.search.video-channels.local.list.params') - - const resultList = await Hooks.wrapPromiseFun( - VideoChannelModel.searchForApi, - apiOptions, - 'filter:api.search.video-channels.local.list.result' - ) - - return res.json(getFormattedObjects(resultList.data, resultList.total)) -} - -async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) { - let videoChannel: MChannelAccountDefault - let uri = search - - if (isWebfingerSearch) { - try { - uri = await loadActorUrlOrGetFromWebfinger(search) - } catch (err) { - logger.warn('Cannot load actor URL or get from webfinger.', { search, err }) - - return res.json({ total: 0, data: [] }) - } - } - - if (isUserAbleToSearchRemoteURI(res)) { - try { - const actor = await getOrCreateAPActor(uri, 'all', true, true) - videoChannel = actor.VideoChannel - } catch (err) { - logger.info('Cannot search remote video channel %s.', uri, { err }) - } - } else { - videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(uri) - } - - return res.json({ - total: videoChannel ? 1 : 0, - data: videoChannel ? [ videoChannel.toFormattedJSON() ] : [] - }) -} - -function searchVideos (req: express.Request, res: express.Response) { - const query: VideosSearchQuery = req.query - const search = query.search - - if (search && (search.startsWith('http://') || search.startsWith('https://'))) { - return searchVideoURI(search, res) - } - - if (isSearchIndexSearch(query)) { - return searchVideosIndex(query, res) - } - - return searchVideosDB(query, res) -} - -async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) { - const result = await buildMutedForSearchIndex(res) - - let body: VideosSearchQuery = Object.assign(query, result) - - // Use the default instance NSFW policy if not specified - if (!body.nsfw) { - const nsfwPolicy = res.locals.oauth - ? res.locals.oauth.token.User.nsfwPolicy - : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY - - body.nsfw = nsfwPolicy === 'do_not_list' - ? 'false' - : 'both' - } - - body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params') - - const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos' - - try { - logger.debug('Doing videos search index request on %s.', url, { body }) - - const { body: searchIndexResult } = await doJSONRequest>(url, { method: 'POST', json: body }) - const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result') - - return res.json(jsonResult) - } catch (err) { - logger.warn('Cannot use search index to make video search.', { err }) - - return res.fail({ - status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, - message: 'Cannot use search index to make video search' - }) - } -} - -async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { - const apiOptions = await Hooks.wrapObject(Object.assign(query, { - includeLocalVideos: true, - nsfw: buildNSFWFilter(res, query.nsfw), - filter: query.filter, - user: res.locals.oauth ? res.locals.oauth.token.User : undefined - }), 'filter:api.search.videos.local.list.params') - - const resultList = await Hooks.wrapPromiseFun( - VideoModel.searchAndPopulateAccountAndServer, - apiOptions, - 'filter:api.search.videos.local.list.result' - ) - - return res.json(getFormattedObjects(resultList.data, resultList.total)) -} - -async function searchVideoURI (url: string, res: express.Response) { - let video: MVideoAccountLightBlacklistAllFiles - - // Check if we can fetch a remote video with the URL - if (isUserAbleToSearchRemoteURI(res)) { - try { - const syncParam = { - likes: false, - dislikes: false, - shares: false, - comments: false, - thumbnail: true, - refreshVideo: false - } - - const result = await getOrCreateAPVideo({ videoObject: url, syncParam }) - video = result ? result.video : undefined - } catch (err) { - logger.info('Cannot search remote video %s.', url, { err }) - } - } else { - video = await VideoModel.loadByUrlAndPopulateAccount(url) - } - - return res.json({ - total: video ? 1 : 0, - data: video ? [ video.toFormattedJSON() ] : [] - }) -} - -function isSearchIndexSearch (query: SearchTargetQuery) { - if (query.searchTarget === 'search-index') return true - - const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX - - if (searchIndexConfig.ENABLED !== true) return false - - if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true - if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true - - return false -} - -async function buildMutedForSearchIndex (res: express.Response) { - const serverActor = await getServerActor() - const accountIds = [ serverActor.Account.id ] - - if (res.locals.oauth) { - accountIds.push(res.locals.oauth.token.User.Account.id) - } - - const [ blockedHosts, blockedAccounts ] = await Promise.all([ - ServerBlocklistModel.listHostsBlockedBy(accountIds), - AccountBlocklistModel.listHandlesBlockedBy(accountIds) - ]) - - return { - blockedHosts, - blockedAccounts - } -} diff --git a/server/controllers/api/search/index.ts b/server/controllers/api/search/index.ts new file mode 100644 index 000000000..67adbb307 --- /dev/null +++ b/server/controllers/api/search/index.ts @@ -0,0 +1,16 @@ +import * as express from 'express' +import { searchChannelsRouter } from './search-video-channels' +import { searchPlaylistsRouter } from './search-video-playlists' +import { searchVideosRouter } from './search-videos' + +const searchRouter = express.Router() + +searchRouter.use('/', searchVideosRouter) +searchRouter.use('/', searchChannelsRouter) +searchRouter.use('/', searchPlaylistsRouter) + +// --------------------------------------------------------------------------- + +export { + searchRouter +} diff --git a/server/controllers/api/search/search-video-channels.ts b/server/controllers/api/search/search-video-channels.ts new file mode 100644 index 000000000..16beeed60 --- /dev/null +++ b/server/controllers/api/search/search-video-channels.ts @@ -0,0 +1,150 @@ +import * as express from 'express' +import { sanitizeUrl } from '@server/helpers/core-utils' +import { doJSONRequest } from '@server/helpers/requests' +import { CONFIG } from '@server/initializers/config' +import { WEBSERVER } from '@server/initializers/constants' +import { Hooks } from '@server/lib/plugins/hooks' +import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' +import { getServerActor } from '@server/models/application/application' +import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' +import { ResultList, VideoChannel } from '@shared/models' +import { VideoChannelsSearchQuery } from '../../../../shared/models/search' +import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' +import { logger } from '../../../helpers/logger' +import { getFormattedObjects } from '../../../helpers/utils' +import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors' +import { + asyncMiddleware, + openapiOperationDoc, + optionalAuthenticate, + paginationValidator, + setDefaultPagination, + setDefaultSearchSort, + videoChannelsListSearchValidator, + videoChannelsSearchSortValidator +} from '../../../middlewares' +import { VideoChannelModel } from '../../../models/video/video-channel' +import { MChannelAccountDefault } from '../../../types/models' + +const searchChannelsRouter = express.Router() + +searchChannelsRouter.get('/video-channels', + openapiOperationDoc({ operationId: 'searchChannels' }), + paginationValidator, + setDefaultPagination, + videoChannelsSearchSortValidator, + setDefaultSearchSort, + optionalAuthenticate, + videoChannelsListSearchValidator, + asyncMiddleware(searchVideoChannels) +) + +// --------------------------------------------------------------------------- + +export { searchChannelsRouter } + +// --------------------------------------------------------------------------- + +function searchVideoChannels (req: express.Request, res: express.Response) { + const query: VideoChannelsSearchQuery = req.query + const search = query.search + + const parts = search.split('@') + + // Handle strings like @toto@example.com + if (parts.length === 3 && parts[0].length === 0) parts.shift() + const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' ')) + + if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res) + + // @username -> username to search in DB + if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '') + + if (isSearchIndexSearch(query)) { + return searchVideoChannelsIndex(query, res) + } + + return searchVideoChannelsDB(query, res) +} + +async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) { + const result = await buildMutedForSearchIndex(res) + + const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params') + + const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels' + + try { + logger.debug('Doing video channels search index request on %s.', url, { body }) + + const { body: searchIndexResult } = await doJSONRequest>(url, { method: 'POST', json: body }) + const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result') + + return res.json(jsonResult) + } catch (err) { + logger.warn('Cannot use search index to make video channels search.', { err }) + + return res.fail({ + status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, + message: 'Cannot use search index to make video channels search' + }) + } +} + +async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { + const serverActor = await getServerActor() + + const apiOptions = await Hooks.wrapObject({ + actorId: serverActor.id, + search: query.search, + start: query.start, + count: query.count, + sort: query.sort + }, 'filter:api.search.video-channels.local.list.params') + + const resultList = await Hooks.wrapPromiseFun( + VideoChannelModel.searchForApi, + apiOptions, + 'filter:api.search.video-channels.local.list.result' + ) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) { + let videoChannel: MChannelAccountDefault + let uri = search + + if (isWebfingerSearch) { + try { + uri = await loadActorUrlOrGetFromWebfinger(search) + } catch (err) { + logger.warn('Cannot load actor URL or get from webfinger.', { search, err }) + + return res.json({ total: 0, data: [] }) + } + } + + if (isUserAbleToSearchRemoteURI(res)) { + try { + const actor = await getOrCreateAPActor(uri, 'all', true, true) + videoChannel = actor.VideoChannel + } catch (err) { + logger.info('Cannot search remote video channel %s.', uri, { err }) + } + } else { + videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(uri)) + } + + return res.json({ + total: videoChannel ? 1 : 0, + data: videoChannel ? [ videoChannel.toFormattedJSON() ] : [] + }) +} + +function sanitizeLocalUrl (url: string) { + if (!url) return '' + + // Handle alternative channel URLs + return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/') +} diff --git a/server/controllers/api/search/search-video-playlists.ts b/server/controllers/api/search/search-video-playlists.ts new file mode 100644 index 000000000..b231ff1e2 --- /dev/null +++ b/server/controllers/api/search/search-video-playlists.ts @@ -0,0 +1,129 @@ +import * as express from 'express' +import { sanitizeUrl } from '@server/helpers/core-utils' +import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils' +import { logger } from '@server/helpers/logger' +import { doJSONRequest } from '@server/helpers/requests' +import { getFormattedObjects } from '@server/helpers/utils' +import { CONFIG } from '@server/initializers/config' +import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get' +import { Hooks } from '@server/lib/plugins/hooks' +import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' +import { getServerActor } from '@server/models/application/application' +import { VideoPlaylistModel } from '@server/models/video/video-playlist' +import { MVideoPlaylistFullSummary } from '@server/types/models' +import { HttpStatusCode } from '@shared/core-utils' +import { ResultList, VideoPlaylist, VideoPlaylistsSearchQuery } from '@shared/models' +import { + asyncMiddleware, + openapiOperationDoc, + optionalAuthenticate, + paginationValidator, + setDefaultPagination, + setDefaultSearchSort, + videoPlaylistsListSearchValidator, + videoPlaylistsSearchSortValidator +} from '../../../middlewares' +import { WEBSERVER } from '@server/initializers/constants' + +const searchPlaylistsRouter = express.Router() + +searchPlaylistsRouter.get('/video-playlists', + openapiOperationDoc({ operationId: 'searchPlaylists' }), + paginationValidator, + setDefaultPagination, + videoPlaylistsSearchSortValidator, + setDefaultSearchSort, + optionalAuthenticate, + videoPlaylistsListSearchValidator, + asyncMiddleware(searchVideoPlaylists) +) + +// --------------------------------------------------------------------------- + +export { searchPlaylistsRouter } + +// --------------------------------------------------------------------------- + +function searchVideoPlaylists (req: express.Request, res: express.Response) { + const query: VideoPlaylistsSearchQuery = req.query + const search = query.search + + if (isURISearch(search)) return searchVideoPlaylistsURI(search, res) + + if (isSearchIndexSearch(query)) { + return searchVideoPlaylistsIndex(query, res) + } + + return searchVideoPlaylistsDB(query, res) +} + +async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQuery, res: express.Response) { + const result = await buildMutedForSearchIndex(res) + + const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params') + + const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists' + + try { + logger.debug('Doing video playlists search index request on %s.', url, { body }) + + const { body: searchIndexResult } = await doJSONRequest>(url, { method: 'POST', json: body }) + const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result') + + return res.json(jsonResult) + } catch (err) { + logger.warn('Cannot use search index to make video playlists search.', { err }) + + return res.fail({ + status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, + message: 'Cannot use search index to make video playlists search' + }) + } +} + +async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQuery, res: express.Response) { + const serverActor = await getServerActor() + + const apiOptions = await Hooks.wrapObject({ + followerActorId: serverActor.id, + search: query.search, + start: query.start, + count: query.count, + sort: query.sort + }, 'filter:api.search.video-playlists.local.list.params') + + const resultList = await Hooks.wrapPromiseFun( + VideoPlaylistModel.searchForApi, + apiOptions, + 'filter:api.search.video-playlists.local.list.result' + ) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function searchVideoPlaylistsURI (search: string, res: express.Response) { + let videoPlaylist: MVideoPlaylistFullSummary + + if (isUserAbleToSearchRemoteURI(res)) { + try { + videoPlaylist = await getOrCreateAPVideoPlaylist(search) + } catch (err) { + logger.info('Cannot search remote video playlist %s.', search, { err }) + } + } else { + videoPlaylist = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(sanitizeLocalUrl(search)) + } + + return res.json({ + total: videoPlaylist ? 1 : 0, + data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : [] + }) +} + +function sanitizeLocalUrl (url: string) { + if (!url) return '' + + // Handle alternative channel URLs + return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/') + .replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/') +} diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts new file mode 100644 index 000000000..b626baa28 --- /dev/null +++ b/server/controllers/api/search/search-videos.ts @@ -0,0 +1,153 @@ +import * as express from 'express' +import { sanitizeUrl } from '@server/helpers/core-utils' +import { doJSONRequest } from '@server/helpers/requests' +import { CONFIG } from '@server/initializers/config' +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 { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' +import { ResultList, Video } from '@shared/models' +import { VideosSearchQuery } from '../../../../shared/models/search' +import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' +import { logger } from '../../../helpers/logger' +import { getFormattedObjects } from '../../../helpers/utils' +import { + asyncMiddleware, + commonVideosFiltersValidator, + openapiOperationDoc, + optionalAuthenticate, + paginationValidator, + setDefaultPagination, + setDefaultSearchSort, + videosSearchSortValidator, + videosSearchValidator +} from '../../../middlewares' +import { VideoModel } from '../../../models/video/video' +import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models' + +const searchVideosRouter = express.Router() + +searchVideosRouter.get('/videos', + openapiOperationDoc({ operationId: 'searchVideos' }), + paginationValidator, + setDefaultPagination, + videosSearchSortValidator, + setDefaultSearchSort, + optionalAuthenticate, + commonVideosFiltersValidator, + videosSearchValidator, + asyncMiddleware(searchVideos) +) + +// --------------------------------------------------------------------------- + +export { searchVideosRouter } + +// --------------------------------------------------------------------------- + +function searchVideos (req: express.Request, res: express.Response) { + const query: VideosSearchQuery = req.query + const search = query.search + + if (isURISearch(search)) { + return searchVideoURI(search, res) + } + + if (isSearchIndexSearch(query)) { + return searchVideosIndex(query, res) + } + + return searchVideosDB(query, res) +} + +async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) { + const result = await buildMutedForSearchIndex(res) + + let body: VideosSearchQuery = Object.assign(query, result) + + // Use the default instance NSFW policy if not specified + if (!body.nsfw) { + const nsfwPolicy = res.locals.oauth + ? res.locals.oauth.token.User.nsfwPolicy + : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY + + body.nsfw = nsfwPolicy === 'do_not_list' + ? 'false' + : 'both' + } + + body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params') + + const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos' + + try { + logger.debug('Doing videos search index request on %s.', url, { body }) + + const { body: searchIndexResult } = await doJSONRequest>(url, { method: 'POST', json: body }) + const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result') + + return res.json(jsonResult) + } catch (err) { + logger.warn('Cannot use search index to make video search.', { err }) + + return res.fail({ + status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, + message: 'Cannot use search index to make video search' + }) + } +} + +async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { + const apiOptions = await Hooks.wrapObject(Object.assign(query, { + includeLocalVideos: true, + nsfw: buildNSFWFilter(res, query.nsfw), + filter: query.filter, + user: res.locals.oauth ? res.locals.oauth.token.User : undefined + }), 'filter:api.search.videos.local.list.params') + + const resultList = await Hooks.wrapPromiseFun( + VideoModel.searchAndPopulateAccountAndServer, + apiOptions, + 'filter:api.search.videos.local.list.result' + ) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function searchVideoURI (url: string, res: express.Response) { + let video: MVideoAccountLightBlacklistAllFiles + + // Check if we can fetch a remote video with the URL + if (isUserAbleToSearchRemoteURI(res)) { + try { + const syncParam = { + likes: false, + dislikes: false, + shares: false, + comments: false, + thumbnail: true, + refreshVideo: false + } + + const result = await getOrCreateAPVideo({ videoObject: url, syncParam }) + video = result ? result.video : undefined + } catch (err) { + logger.info('Cannot search remote video %s.', url, { err }) + } + } else { + video = await VideoModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(url)) + } + + return res.json({ + total: video ? 1 : 0, + data: video ? [ video.toFormattedJSON() ] : [] + }) +} + +function sanitizeLocalUrl (url: string) { + if (!url) return '' + + // Handle alternative video URLs + return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/') +} diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 03aa918d3..bc8d203b0 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -32,7 +32,7 @@ import { videoChannelsUpdateValidator, videoPlaylistsSortValidator } from '../../middlewares' -import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators' +import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' import { AccountModel } from '../../models/account/account' @@ -51,7 +51,7 @@ videoChannelRouter.get('/', videoChannelsSortValidator, setDefaultSort, setDefaultPagination, - videoChannelsOwnSearchValidator, + videoChannelsListValidator, asyncMiddleware(listVideoChannels) ) diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts index 7c816b93a..c25aed20b 100644 --- a/server/controllers/api/video-playlist.ts +++ b/server/controllers/api/video-playlist.ts @@ -1,7 +1,9 @@ import * as express from 'express' import { join } from 'path' +import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists' import { getServerActor } from '@server/models/application/application' import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' +import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' @@ -17,7 +19,6 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant import { sequelizeTypescript } from '../../initializers/database' import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' -import { JobQueue } from '../../lib/job-queue' import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail' import { asyncMiddleware, @@ -42,7 +43,6 @@ import { import { AccountModel } from '../../models/account/account' import { VideoPlaylistModel } from '../../models/video/video-playlist' import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' -import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) @@ -144,9 +144,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response) function getVideoPlaylist (req: express.Request, res: express.Response) { const videoPlaylist = res.locals.videoPlaylistSummary - if (videoPlaylist.isOutdated()) { - JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: videoPlaylist.url } }) - } + scheduleRefreshIfNeeded(videoPlaylist) return res.json(videoPlaylist.toFormattedJSON()) } diff --git a/server/helpers/custom-validators/activitypub/playlist.ts b/server/helpers/custom-validators/activitypub/playlist.ts index bd0d16a4a..72c5b80e9 100644 --- a/server/helpers/custom-validators/activitypub/playlist.ts +++ b/server/helpers/custom-validators/activitypub/playlist.ts @@ -1,13 +1,16 @@ -import { exists, isDateValid } from '../misc' -import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' import validator from 'validator' import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' +import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' +import { exists, isDateValid, isUUIDValid } from '../misc' +import { isVideoPlaylistNameValid } from '../video-playlists' import { isActivityPubUrlValid } from './misc' function isPlaylistObjectValid (object: PlaylistObject) { return exists(object) && object.type === 'Playlist' && validator.isInt(object.totalItems + '') && + isVideoPlaylistNameValid(object.name) && + isUUIDValid(object.uuid) && isDateValid(object.published) && isDateValid(object.updated) } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 06b4e5a18..cd00b73d5 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -77,6 +77,7 @@ const SORTABLE_COLUMNS = { // Don't forget to update peertube-search-index with the same values VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], + VIDEO_PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ], ABUSES: [ 'id', 'createdAt', 'state' ], diff --git a/server/lib/activitypub/actors/get.ts b/server/lib/activitypub/actors/get.ts index d7cf2b678..8681ea02a 100644 --- a/server/lib/activitypub/actors/get.ts +++ b/server/lib/activitypub/actors/get.ts @@ -116,7 +116,7 @@ async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, ref async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) { // We created a new account: fetch the playlists if (created === true && actor.Account && accountPlaylistsUrl) { - const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' } + const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' } await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }) } } diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts index 37d748de4..ea3e61ac5 100644 --- a/server/lib/activitypub/playlists/create-update.ts +++ b/server/lib/activitypub/playlists/create-update.ts @@ -1,3 +1,5 @@ +import * as Bluebird from 'bluebird' +import { getAPId } from '@server/helpers/activitypub' import { isArray } from '@server/helpers/custom-validators/misc' import { logger, loggerTagsFactory } from '@server/helpers/logger' import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' @@ -6,7 +8,7 @@ import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail' import { VideoPlaylistModel } from '@server/models/video/video-playlist' import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' import { FilteredModelAttributes } from '@server/types' -import { MAccountDefault, MAccountId, MThumbnail, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models' +import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models' import { AttributesOnly } from '@shared/core-utils' import { PlaylistObject } from '@shared/models' import { getOrCreateAPActor } from '../actors' @@ -19,11 +21,9 @@ import { playlistObjectToDBAttributes } from './shared' -import Bluebird = require('bluebird') - const lTags = loggerTagsFactory('ap', 'video-playlist') -async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) { +async function createAccountPlaylists (playlistUrls: string[]) { await Bluebird.map(playlistUrls, async playlistUrl => { try { const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) @@ -35,19 +35,19 @@ async function createAccountPlaylists (playlistUrls: string[], account: MAccount throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) } - return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to) + return createOrUpdateVideoPlaylist(playlistObject) } catch (err) { logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) }) } }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) } -async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { - const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to) +async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) { + const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to) - await setVideoChannelIfNeeded(playlistObject, playlistAttributes) + await setVideoChannel(playlistObject, playlistAttributes) - const [ upsertPlaylist ] = await VideoPlaylistModel.upsert(playlistAttributes, { returning: true }) + const [ upsertPlaylist ] = await VideoPlaylistModel.upsert(playlistAttributes, { returning: true }) const playlistElementUrls = await fetchElementUrls(playlistObject) @@ -56,7 +56,10 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc await updatePlaylistThumbnail(playlistObject, playlist) - return rebuildVideoPlaylistElements(playlistElementUrls, playlist) + const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist) + playlist.setVideosLength(elementsLength) + + return playlist } // --------------------------------------------------------------------------- @@ -68,10 +71,12 @@ export { // --------------------------------------------------------------------------- -async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly) { - if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return +async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly) { + if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) { + throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject)) + } - const actor = await getOrCreateAPActor(playlistObject.attributedTo[0]) + const actor = await getOrCreateAPActor(playlistObject.attributedTo[0], 'all') if (!actor.VideoChannel) { logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) @@ -79,6 +84,7 @@ async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlist } playlistAttributes.videoChannelId = actor.VideoChannel.id + playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id } async function fetchElementUrls (playlistObject: PlaylistObject) { @@ -128,7 +134,7 @@ async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MV logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url)) - return undefined + return elementsToCreate.length } async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) { diff --git a/server/lib/activitypub/playlists/get.ts b/server/lib/activitypub/playlists/get.ts new file mode 100644 index 000000000..2c19c503a --- /dev/null +++ b/server/lib/activitypub/playlists/get.ts @@ -0,0 +1,35 @@ +import { getAPId } from '@server/helpers/activitypub' +import { VideoPlaylistModel } from '@server/models/video/video-playlist' +import { MVideoPlaylistFullSummary } from '@server/types/models' +import { APObject } from '@shared/models' +import { createOrUpdateVideoPlaylist } from './create-update' +import { scheduleRefreshIfNeeded } from './refresh' +import { fetchRemoteVideoPlaylist } from './shared' + +async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObject): Promise { + const playlistUrl = getAPId(playlistObjectArg) + + const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl) + + if (playlistFromDatabase) { + scheduleRefreshIfNeeded(playlistFromDatabase) + + return playlistFromDatabase + } + + const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl) + if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl) + + // playlistUrl is just an alias/rediraction, so process object id instead + if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject) + + const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject) + + return playlistCreated +} + +// --------------------------------------------------------------------------- + +export { + getOrCreateAPVideoPlaylist +} diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts index 2885830b4..e2470a674 100644 --- a/server/lib/activitypub/playlists/index.ts +++ b/server/lib/activitypub/playlists/index.ts @@ -1,2 +1,3 @@ +export * from './get' export * from './create-update' export * from './refresh' diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts index 6f3a6be37..ef3cb3fe4 100644 --- a/server/lib/activitypub/playlists/refresh.ts +++ b/server/lib/activitypub/playlists/refresh.ts @@ -1,10 +1,17 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger' import { PeerTubeRequestError } from '@server/helpers/requests' -import { MVideoPlaylistOwner } from '@server/types/models' +import { JobQueue } from '@server/lib/job-queue' +import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models' import { HttpStatusCode } from '@shared/core-utils' import { createOrUpdateVideoPlaylist } from './create-update' import { fetchRemoteVideoPlaylist } from './shared' +function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) { + if (!playlist.isOutdated()) return + + JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } }) +} + async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise { if (!videoPlaylist.isOutdated()) return videoPlaylist @@ -22,8 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner) return videoPlaylist } - const byAccount = videoPlaylist.OwnerAccount - await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to) + await createOrUpdateVideoPlaylist(playlistObject) return videoPlaylist } catch (err) { @@ -42,5 +48,6 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner) } export { + scheduleRefreshIfNeeded, refreshVideoPlaylistIfNeeded } diff --git a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts index 6ec44485e..70fd335bc 100644 --- a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts +++ b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts @@ -1,11 +1,11 @@ import { ACTIVITY_PUB } from '@server/initializers/constants' import { VideoPlaylistModel } from '@server/models/video/video-playlist' import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' -import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models' +import { MVideoId, MVideoPlaylistId } from '@server/types/models' import { AttributesOnly } from '@shared/core-utils' import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' -function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { +function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) { const privacy = to.includes(ACTIVITY_PUB.PUBLIC) ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED @@ -16,7 +16,7 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount privacy, url: playlistObject.id, uuid: playlistObject.uuid, - ownerAccountId: byAccount.id, + ownerAccountId: null, videoChannelId: null, createdAt: new Date(playlistObject.published), updatedAt: new Date(playlistObject.updated) diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index 6b7f5aae8..70e048d6e 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -128,5 +128,5 @@ async function processCreatePlaylist (activity: ActivityCreate, byActor: MActorS if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) - await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) + await createOrUpdateVideoPlaylist(playlistObject, activity.to) } diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index aa80d5d09..f40008a6b 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts @@ -111,5 +111,5 @@ async function processUpdatePlaylist (byActor: MActorSignature, activity: Activi if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) - await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) + await createOrUpdateVideoPlaylist(playlistObject, activity.to) } diff --git a/server/lib/activitypub/videos/get.ts b/server/lib/activitypub/videos/get.ts index 7bb14adc4..f3e2f0625 100644 --- a/server/lib/activitypub/videos/get.ts +++ b/server/lib/activitypub/videos/get.ts @@ -3,6 +3,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils' import { JobQueue } from '@server/lib/job-queue' import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' +import { APObject } from '@shared/models' import { refreshVideoIfNeeded } from './refresh' import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' @@ -13,21 +14,21 @@ type GetVideoResult = Promise<{ }> type GetVideoParamAll = { - videoObject: { id: string } | string + videoObject: APObject syncParam?: SyncParam fetchType?: 'all' allowRefresh?: boolean } type GetVideoParamImmutable = { - videoObject: { id: string } | string + videoObject: APObject syncParam?: SyncParam fetchType: 'only-immutable-attributes' allowRefresh: false } type GetVideoParamOther = { - videoObject: { id: string } | string + videoObject: APObject syncParam?: SyncParam fetchType?: 'all' | 'only-video' allowRefresh?: boolean diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts index 04b25f955..ab9675cae 100644 --- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts +++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts @@ -1,12 +1,11 @@ import * as Bull from 'bull' import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models' import { logger } from '../../../helpers/logger' -import { AccountModel } from '../../../models/account/account' import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { VideoModel } from '../../../models/video/video' import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoShareModel } from '../../../models/video/video-share' -import { MAccountDefault, MVideoFullLight } from '../../../types/models' +import { MVideoFullLight } from '../../../types/models' import { crawlCollectionPage } from '../../activitypub/crawl' import { createAccountPlaylists } from '../../activitypub/playlists' import { processActivities } from '../../activitypub/process' @@ -22,16 +21,13 @@ async function processActivityPubHttpFetcher (job: Bull.Job) { let video: MVideoFullLight if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) - let account: MAccountDefault - if (payload.accountId) account = await AccountModel.load(payload.accountId) - const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise } = { 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }), 'video-likes': items => createRates(items, video, 'like'), 'video-dislikes': items => createRates(items, video, 'dislike'), 'video-shares': items => addVideoShares(items, video), 'video-comments': items => addVideoComments(items), - 'account-playlists': items => createAccountPlaylists(items, account) + 'account-playlists': items => createAccountPlaylists(items) } const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise } = { diff --git a/server/lib/search.ts b/server/lib/search.ts new file mode 100644 index 000000000..b643a4055 --- /dev/null +++ b/server/lib/search.ts @@ -0,0 +1,50 @@ +import * as express from 'express' +import { CONFIG } from '@server/initializers/config' +import { AccountBlocklistModel } from '@server/models/account/account-blocklist' +import { getServerActor } from '@server/models/application/application' +import { ServerBlocklistModel } from '@server/models/server/server-blocklist' +import { SearchTargetQuery } from '@shared/models' + +function isSearchIndexSearch (query: SearchTargetQuery) { + if (query.searchTarget === 'search-index') return true + + const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX + + if (searchIndexConfig.ENABLED !== true) return false + + if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true + if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true + + return false +} + +async function buildMutedForSearchIndex (res: express.Response) { + const serverActor = await getServerActor() + const accountIds = [ serverActor.Account.id ] + + if (res.locals.oauth) { + accountIds.push(res.locals.oauth.token.User.Account.id) + } + + const [ blockedHosts, blockedAccounts ] = await Promise.all([ + ServerBlocklistModel.listHostsBlockedBy(accountIds), + AccountBlocklistModel.listHandlesBlockedBy(accountIds) + ]) + + return { + blockedHosts, + blockedAccounts + } +} + +function isURISearch (search: string) { + if (!search) return false + + return search.startsWith('http://') || search.startsWith('https://') +} + +export { + isSearchIndexSearch, + buildMutedForSearchIndex, + isURISearch +} diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts index e2e1c6aae..7bbf81048 100644 --- a/server/middlewares/validators/search.ts +++ b/server/middlewares/validators/search.ts @@ -49,11 +49,12 @@ const videoChannelsListSearchValidator = [ } ] -const videoChannelsOwnSearchValidator = [ - query('search').optional().not().isEmpty().withMessage('Should have a valid search'), +const videoPlaylistsListSearchValidator = [ + query('search').not().isEmpty().withMessage('Should have a valid search'), + query('searchTarget').optional().custom(isSearchTargetValid).withMessage('Should have a valid search target'), (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking video channels search query', { parameters: req.query }) + logger.debug('Checking video playlists search query', { parameters: req.query }) if (areValidationErrors(req, res)) return @@ -66,5 +67,5 @@ const videoChannelsOwnSearchValidator = [ export { videosSearchValidator, videoChannelsListSearchValidator, - videoChannelsOwnSearchValidator + videoPlaylistsListSearchValidator } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index d67b6f3ba..473010460 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -9,6 +9,7 @@ const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES) const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) +const SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH) const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) @@ -34,6 +35,7 @@ const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) +const videoPlaylistsSearchSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS) const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS) const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS) @@ -75,5 +77,6 @@ export { userNotificationsSortValidator, videoPlaylistsSortValidator, videoRedundanciesSortValidator, + videoPlaylistsSearchSortValidator, pluginsSortValidator } diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts index 911a25bfb..e7df185e4 100644 --- a/server/middlewares/validators/videos/video-channels.ts +++ b/server/middlewares/validators/videos/video-channels.ts @@ -141,6 +141,18 @@ const videoChannelStatsValidator = [ } ] +const videoChannelsListValidator = [ + query('search').optional().not().isEmpty().withMessage('Should have a valid search'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking video channels search query', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -148,6 +160,7 @@ export { videoChannelsUpdateValidator, videoChannelsRemoveValidator, videoChannelsNameWithHostValidator, + videoChannelsListValidator, localVideoChannelValidator, videoChannelStatsValidator } diff --git a/server/models/video/sql/shared/abstract-videos-query-builder.ts b/server/models/video/sql/shared/abstract-videos-query-builder.ts index 3a1ee5b1f..09776bcb0 100644 --- a/server/models/video/sql/shared/abstract-videos-query-builder.ts +++ b/server/models/video/sql/shared/abstract-videos-query-builder.ts @@ -18,7 +18,7 @@ export class AbstractVideosQueryBuilder { logging: options.logging, replacements: this.replacements, type: QueryTypes.SELECT as QueryTypes.SELECT, - next: false + nest: false } return this.sequelize.query(this.query, queryOptions) diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 33749ea70..f84b85290 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -434,8 +434,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"` sort: string }) { const attributesInclude = [] - const escapedSearch = VideoModel.sequelize.escape(options.search) - const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') + const escapedSearch = VideoChannelModel.sequelize.escape(options.search) + const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%') attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search)) const query = { diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 1a05f8d42..7aa6b6c6e 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -1,5 +1,5 @@ import { join } from 'path' -import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' +import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' import { AllowNull, BelongsTo, @@ -53,7 +53,15 @@ import { } from '../../types/models/video/video-playlist' import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' import { ActorModel } from '../actor/actor' -import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils' +import { + buildServerIdsFollowedBy, + buildTrigramSearchIndex, + buildWhereIdOrUUID, + createSimilarityAttribute, + getPlaylistSort, + isOutdated, + throwIfNotValid +} from '../utils' import { ThumbnailModel } from './thumbnail' import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' import { VideoPlaylistElementModel } from './video-playlist-element' @@ -74,6 +82,11 @@ type AvailableForListOptions = { videoChannelId?: number listMyPlaylists?: boolean search?: string + withVideos?: boolean +} + +function getVideoLengthSelect () { + return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"' } @Scopes(() => ({ @@ -89,7 +102,7 @@ type AvailableForListOptions = { attributes: { include: [ [ - literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), + literal(`(${getVideoLengthSelect()})`), 'videosLength' ] ] @@ -178,11 +191,28 @@ type AvailableForListOptions = { }) } + if (options.withVideos === true) { + whereAnd.push( + literal(`(${getVideoLengthSelect()}) != 0`) + ) + } + + const attributesInclude = [] + if (options.search) { + const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search) + const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%') + attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search)) + whereAnd.push({ - name: { - [Op.iLike]: '%' + options.search + '%' - } + [Op.or]: [ + Sequelize.literal( + 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' + ), + Sequelize.literal( + 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + ) + ] }) } @@ -191,6 +221,9 @@ type AvailableForListOptions = { } return { + attributes: { + include: attributesInclude + }, where, include: [ { @@ -211,6 +244,8 @@ type AvailableForListOptions = { @Table({ tableName: 'videoPlaylist', indexes: [ + buildTrigramSearchIndex('video_playlist_name_trigram', 'name'), + { fields: [ 'ownerAccountId' ] }, @@ -314,6 +349,7 @@ export class VideoPlaylistModel extends Model { + const query = { + where: { + url + } + } + + return VideoPlaylistModel + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) + .findOne(query) + } + static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' } @@ -535,6 +599,10 @@ export class VideoPlaylistModel extends Model addToCount(obj) + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ############################################################################ + +function addToCount (obj) { + return Object.assign({}, obj, { count: obj.count + 1 }) +} diff --git a/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json new file mode 100644 index 000000000..2adce4743 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json @@ -0,0 +1,23 @@ +{ + "name": "peertube-plugin-test-filter-translations", + "version": "0.0.1", + "description": "Plugin test filter and translations", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": { + "fr-FR": "./languages/fr.json", + "it-IT": "./languages/it.json" + } +} diff --git a/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json b/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json deleted file mode 100644 index 52d8313df..000000000 --- a/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "Hello world": "Bonjour le monde" -} diff --git a/server/tests/fixtures/peertube-plugin-test-two/languages/it.json b/server/tests/fixtures/peertube-plugin-test-two/languages/it.json deleted file mode 100644 index 9e187d83b..000000000 --- a/server/tests/fixtures/peertube-plugin-test-two/languages/it.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "Hello world": "Ciao, mondo!" -} diff --git a/server/tests/fixtures/peertube-plugin-test-two/main.js b/server/tests/fixtures/peertube-plugin-test-two/main.js deleted file mode 100644 index 71c11b2ba..000000000 --- a/server/tests/fixtures/peertube-plugin-test-two/main.js +++ /dev/null @@ -1,21 +0,0 @@ -async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) { - registerHook({ - target: 'filter:api.videos.list.params', - handler: obj => addToCount(obj) - }) -} - -async function unregister () { - return -} - -module.exports = { - register, - unregister -} - -// ############################################################################ - -function addToCount (obj) { - return Object.assign({}, obj, { count: obj.count + 1 }) -} diff --git a/server/tests/fixtures/peertube-plugin-test-two/package.json b/server/tests/fixtures/peertube-plugin-test-two/package.json deleted file mode 100644 index 926f2d69b..000000000 --- a/server/tests/fixtures/peertube-plugin-test-two/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "peertube-plugin-test-two", - "version": "0.0.1", - "description": "Plugin test 2", - "engine": { - "peertube": ">=1.3.0" - }, - "keywords": [ - "peertube", - "plugin" - ], - "homepage": "https://github.com/Chocobozzz/PeerTube", - "author": "Chocobozzz", - "bugs": "https://github.com/Chocobozzz/PeerTube/issues", - "library": "./main.js", - "staticDirs": {}, - "css": [], - "clientScripts": [], - "translations": { - "fr-FR": "./languages/fr.json", - "it-IT": "./languages/it.json" - } -} diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js index ee0bc39f3..5e922ad1f 100644 --- a/server/tests/fixtures/peertube-plugin-test/main.js +++ b/server/tests/fixtures/peertube-plugin-test/main.js @@ -241,6 +241,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora 'filter:api.search.video-channels.local.list.result', 'filter:api.search.video-channels.index.list.params', 'filter:api.search.video-channels.index.list.result', + 'filter:api.search.video-playlists.local.list.params', + 'filter:api.search.video-playlists.local.list.result', + 'filter:api.search.video-playlists.index.list.params', + 'filter:api.search.video-playlists.index.list.result' ] for (const h of searchHooks) { diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts index a947283c2..644b41dea 100644 --- a/server/tests/plugins/filter-hooks.ts +++ b/server/tests/plugins/filter-hooks.ts @@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code import { addVideoCommentReply, addVideoCommentThread, + advancedVideoPlaylistSearch, advancedVideosSearch, createLive, createVideoPlaylist, @@ -71,7 +72,7 @@ describe('Test plugin filter hooks', function () { await installPlugin({ url: servers[0].url, accessToken: servers[0].accessToken, - path: getPluginTestPath('-two') + path: getPluginTestPath('-filter-translations') }) for (let i = 0; i < 10; i++) { @@ -525,6 +526,27 @@ describe('Test plugin filter hooks', function () { await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1) await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1) }) + + it('Should run filter:api.search.video-playlists.local.list.{params,result}', async function () { + await advancedVideoPlaylistSearch(servers[0].url, { + search: 'Sun Jian' + }) + + await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1) + await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1) + }) + + it('Should run filter:api.search.video-playlists.index.list.{params,result}', async function () { + await advancedVideoPlaylistSearch(servers[0].url, { + search: 'Sun Jian', + searchTarget: 'search-index' + }) + + await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1) + await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1) + await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.params', 1) + await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.result', 1) + }) }) after(async function () { diff --git a/server/tests/plugins/translations.ts b/server/tests/plugins/translations.ts index 8dc2043b8..9fd2ba1c5 100644 --- a/server/tests/plugins/translations.ts +++ b/server/tests/plugins/translations.ts @@ -31,7 +31,7 @@ describe('Test plugin translations', function () { await installPlugin({ url: server.url, accessToken: server.accessToken, - path: getPluginTestPath('-two') + path: getPluginTestPath('-filter-translations') }) }) @@ -48,7 +48,7 @@ describe('Test plugin translations', function () { 'peertube-plugin-test': { Hi: 'Coucou' }, - 'peertube-plugin-test-two': { + 'peertube-plugin-test-filter-translations': { 'Hello world': 'Bonjour le monde' } }) @@ -58,14 +58,14 @@ describe('Test plugin translations', function () { const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' }) expect(res.body).to.deep.equal({ - 'peertube-plugin-test-two': { + 'peertube-plugin-test-filter-translations': { 'Hello world': 'Ciao, mondo!' } }) }) it('Should remove the plugin and remove the locales', async function () { - await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-two' }) + await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-filter-translations' }) { const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' }) diff --git a/server/types/models/video/video-playlist.ts b/server/types/models/video/video-playlist.ts index 79e2daebf..2f9537cf5 100644 --- a/server/types/models/video/video-playlist.ts +++ b/server/types/models/video/video-playlist.ts @@ -69,7 +69,7 @@ export type MVideoPlaylistAccountChannelDefault = // With all associations export type MVideoPlaylistFull = - MVideoPlaylist & + MVideoPlaylistVideosLength & Use<'OwnerAccount', MAccountDefault> & Use<'VideoChannel', MChannelDefault> & Use<'Thumbnail', MThumbnail> @@ -84,7 +84,7 @@ export type MVideoPlaylistAccountChannelSummary = Use<'VideoChannel', MChannelSummary> export type MVideoPlaylistFullSummary = - MVideoPlaylist & + MVideoPlaylistVideosLength & Use<'Thumbnail', MThumbnail> & Use<'OwnerAccount', MAccountSummary> & Use<'VideoChannel', MChannelSummary> -- cgit v1.2.3