diff options
author | Chocobozzz <me@florianbigard.com> | 2021-06-17 16:02:38 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2021-06-25 14:44:01 +0200 |
commit | 37a44fc915eef2140e22ceb96aba6b6eb2509007 (patch) | |
tree | dd4a370ecc96cf38c99b940261aadc27065da7ae /server/controllers/api/search | |
parent | 33eb19e5199cc9fa4d73c6675c97508e3e072ef9 (diff) | |
download | PeerTube-37a44fc915eef2140e22ceb96aba6b6eb2509007.tar.gz PeerTube-37a44fc915eef2140e22ceb96aba6b6eb2509007.tar.zst PeerTube-37a44fc915eef2140e22ceb96aba6b6eb2509007.zip |
Add ability to search playlists
Diffstat (limited to 'server/controllers/api/search')
-rw-r--r-- | server/controllers/api/search/index.ts | 16 | ||||
-rw-r--r-- | server/controllers/api/search/search-video-channels.ts | 150 | ||||
-rw-r--r-- | server/controllers/api/search/search-video-playlists.ts | 129 | ||||
-rw-r--r-- | server/controllers/api/search/search-videos.ts | 153 |
4 files changed, 448 insertions, 0 deletions
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { searchChannelsRouter } from './search-video-channels' | ||
3 | import { searchPlaylistsRouter } from './search-video-playlists' | ||
4 | import { searchVideosRouter } from './search-videos' | ||
5 | |||
6 | const searchRouter = express.Router() | ||
7 | |||
8 | searchRouter.use('/', searchVideosRouter) | ||
9 | searchRouter.use('/', searchChannelsRouter) | ||
10 | searchRouter.use('/', searchPlaylistsRouter) | ||
11 | |||
12 | // --------------------------------------------------------------------------- | ||
13 | |||
14 | export { | ||
15 | searchRouter | ||
16 | } | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { WEBSERVER } from '@server/initializers/constants' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
8 | import { getServerActor } from '@server/models/application/application' | ||
9 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | ||
10 | import { ResultList, VideoChannel } from '@shared/models' | ||
11 | import { VideoChannelsSearchQuery } from '../../../../shared/models/search' | ||
12 | import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' | ||
13 | import { logger } from '../../../helpers/logger' | ||
14 | import { getFormattedObjects } from '../../../helpers/utils' | ||
15 | import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors' | ||
16 | import { | ||
17 | asyncMiddleware, | ||
18 | openapiOperationDoc, | ||
19 | optionalAuthenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSearchSort, | ||
23 | videoChannelsListSearchValidator, | ||
24 | videoChannelsSearchSortValidator | ||
25 | } from '../../../middlewares' | ||
26 | import { VideoChannelModel } from '../../../models/video/video-channel' | ||
27 | import { MChannelAccountDefault } from '../../../types/models' | ||
28 | |||
29 | const searchChannelsRouter = express.Router() | ||
30 | |||
31 | searchChannelsRouter.get('/video-channels', | ||
32 | openapiOperationDoc({ operationId: 'searchChannels' }), | ||
33 | paginationValidator, | ||
34 | setDefaultPagination, | ||
35 | videoChannelsSearchSortValidator, | ||
36 | setDefaultSearchSort, | ||
37 | optionalAuthenticate, | ||
38 | videoChannelsListSearchValidator, | ||
39 | asyncMiddleware(searchVideoChannels) | ||
40 | ) | ||
41 | |||
42 | // --------------------------------------------------------------------------- | ||
43 | |||
44 | export { searchChannelsRouter } | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | function searchVideoChannels (req: express.Request, res: express.Response) { | ||
49 | const query: VideoChannelsSearchQuery = req.query | ||
50 | const search = query.search | ||
51 | |||
52 | const parts = search.split('@') | ||
53 | |||
54 | // Handle strings like @toto@example.com | ||
55 | if (parts.length === 3 && parts[0].length === 0) parts.shift() | ||
56 | const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' ')) | ||
57 | |||
58 | if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res) | ||
59 | |||
60 | // @username -> username to search in DB | ||
61 | if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '') | ||
62 | |||
63 | if (isSearchIndexSearch(query)) { | ||
64 | return searchVideoChannelsIndex(query, res) | ||
65 | } | ||
66 | |||
67 | return searchVideoChannelsDB(query, res) | ||
68 | } | ||
69 | |||
70 | async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) { | ||
71 | const result = await buildMutedForSearchIndex(res) | ||
72 | |||
73 | const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params') | ||
74 | |||
75 | const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels' | ||
76 | |||
77 | try { | ||
78 | logger.debug('Doing video channels search index request on %s.', url, { body }) | ||
79 | |||
80 | const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body }) | ||
81 | const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result') | ||
82 | |||
83 | return res.json(jsonResult) | ||
84 | } catch (err) { | ||
85 | logger.warn('Cannot use search index to make video channels search.', { err }) | ||
86 | |||
87 | return res.fail({ | ||
88 | status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, | ||
89 | message: 'Cannot use search index to make video channels search' | ||
90 | }) | ||
91 | } | ||
92 | } | ||
93 | |||
94 | async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { | ||
95 | const serverActor = await getServerActor() | ||
96 | |||
97 | const apiOptions = await Hooks.wrapObject({ | ||
98 | actorId: serverActor.id, | ||
99 | search: query.search, | ||
100 | start: query.start, | ||
101 | count: query.count, | ||
102 | sort: query.sort | ||
103 | }, 'filter:api.search.video-channels.local.list.params') | ||
104 | |||
105 | const resultList = await Hooks.wrapPromiseFun( | ||
106 | VideoChannelModel.searchForApi, | ||
107 | apiOptions, | ||
108 | 'filter:api.search.video-channels.local.list.result' | ||
109 | ) | ||
110 | |||
111 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
112 | } | ||
113 | |||
114 | async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) { | ||
115 | let videoChannel: MChannelAccountDefault | ||
116 | let uri = search | ||
117 | |||
118 | if (isWebfingerSearch) { | ||
119 | try { | ||
120 | uri = await loadActorUrlOrGetFromWebfinger(search) | ||
121 | } catch (err) { | ||
122 | logger.warn('Cannot load actor URL or get from webfinger.', { search, err }) | ||
123 | |||
124 | return res.json({ total: 0, data: [] }) | ||
125 | } | ||
126 | } | ||
127 | |||
128 | if (isUserAbleToSearchRemoteURI(res)) { | ||
129 | try { | ||
130 | const actor = await getOrCreateAPActor(uri, 'all', true, true) | ||
131 | videoChannel = actor.VideoChannel | ||
132 | } catch (err) { | ||
133 | logger.info('Cannot search remote video channel %s.', uri, { err }) | ||
134 | } | ||
135 | } else { | ||
136 | videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(uri)) | ||
137 | } | ||
138 | |||
139 | return res.json({ | ||
140 | total: videoChannel ? 1 : 0, | ||
141 | data: videoChannel ? [ videoChannel.toFormattedJSON() ] : [] | ||
142 | }) | ||
143 | } | ||
144 | |||
145 | function sanitizeLocalUrl (url: string) { | ||
146 | if (!url) return '' | ||
147 | |||
148 | // Handle alternative channel URLs | ||
149 | return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/') | ||
150 | } | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils' | ||
4 | import { logger } from '@server/helpers/logger' | ||
5 | import { doJSONRequest } from '@server/helpers/requests' | ||
6 | import { getFormattedObjects } from '@server/helpers/utils' | ||
7 | import { CONFIG } from '@server/initializers/config' | ||
8 | import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get' | ||
9 | import { Hooks } from '@server/lib/plugins/hooks' | ||
10 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
11 | import { getServerActor } from '@server/models/application/application' | ||
12 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
13 | import { MVideoPlaylistFullSummary } from '@server/types/models' | ||
14 | import { HttpStatusCode } from '@shared/core-utils' | ||
15 | import { ResultList, VideoPlaylist, VideoPlaylistsSearchQuery } from '@shared/models' | ||
16 | import { | ||
17 | asyncMiddleware, | ||
18 | openapiOperationDoc, | ||
19 | optionalAuthenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSearchSort, | ||
23 | videoPlaylistsListSearchValidator, | ||
24 | videoPlaylistsSearchSortValidator | ||
25 | } from '../../../middlewares' | ||
26 | import { WEBSERVER } from '@server/initializers/constants' | ||
27 | |||
28 | const searchPlaylistsRouter = express.Router() | ||
29 | |||
30 | searchPlaylistsRouter.get('/video-playlists', | ||
31 | openapiOperationDoc({ operationId: 'searchPlaylists' }), | ||
32 | paginationValidator, | ||
33 | setDefaultPagination, | ||
34 | videoPlaylistsSearchSortValidator, | ||
35 | setDefaultSearchSort, | ||
36 | optionalAuthenticate, | ||
37 | videoPlaylistsListSearchValidator, | ||
38 | asyncMiddleware(searchVideoPlaylists) | ||
39 | ) | ||
40 | |||
41 | // --------------------------------------------------------------------------- | ||
42 | |||
43 | export { searchPlaylistsRouter } | ||
44 | |||
45 | // --------------------------------------------------------------------------- | ||
46 | |||
47 | function searchVideoPlaylists (req: express.Request, res: express.Response) { | ||
48 | const query: VideoPlaylistsSearchQuery = req.query | ||
49 | const search = query.search | ||
50 | |||
51 | if (isURISearch(search)) return searchVideoPlaylistsURI(search, res) | ||
52 | |||
53 | if (isSearchIndexSearch(query)) { | ||
54 | return searchVideoPlaylistsIndex(query, res) | ||
55 | } | ||
56 | |||
57 | return searchVideoPlaylistsDB(query, res) | ||
58 | } | ||
59 | |||
60 | async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQuery, res: express.Response) { | ||
61 | const result = await buildMutedForSearchIndex(res) | ||
62 | |||
63 | const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params') | ||
64 | |||
65 | const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists' | ||
66 | |||
67 | try { | ||
68 | logger.debug('Doing video playlists search index request on %s.', url, { body }) | ||
69 | |||
70 | const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body }) | ||
71 | const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result') | ||
72 | |||
73 | return res.json(jsonResult) | ||
74 | } catch (err) { | ||
75 | logger.warn('Cannot use search index to make video playlists search.', { err }) | ||
76 | |||
77 | return res.fail({ | ||
78 | status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, | ||
79 | message: 'Cannot use search index to make video playlists search' | ||
80 | }) | ||
81 | } | ||
82 | } | ||
83 | |||
84 | async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQuery, res: express.Response) { | ||
85 | const serverActor = await getServerActor() | ||
86 | |||
87 | const apiOptions = await Hooks.wrapObject({ | ||
88 | followerActorId: serverActor.id, | ||
89 | search: query.search, | ||
90 | start: query.start, | ||
91 | count: query.count, | ||
92 | sort: query.sort | ||
93 | }, 'filter:api.search.video-playlists.local.list.params') | ||
94 | |||
95 | const resultList = await Hooks.wrapPromiseFun( | ||
96 | VideoPlaylistModel.searchForApi, | ||
97 | apiOptions, | ||
98 | 'filter:api.search.video-playlists.local.list.result' | ||
99 | ) | ||
100 | |||
101 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
102 | } | ||
103 | |||
104 | async function searchVideoPlaylistsURI (search: string, res: express.Response) { | ||
105 | let videoPlaylist: MVideoPlaylistFullSummary | ||
106 | |||
107 | if (isUserAbleToSearchRemoteURI(res)) { | ||
108 | try { | ||
109 | videoPlaylist = await getOrCreateAPVideoPlaylist(search) | ||
110 | } catch (err) { | ||
111 | logger.info('Cannot search remote video playlist %s.', search, { err }) | ||
112 | } | ||
113 | } else { | ||
114 | videoPlaylist = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(sanitizeLocalUrl(search)) | ||
115 | } | ||
116 | |||
117 | return res.json({ | ||
118 | total: videoPlaylist ? 1 : 0, | ||
119 | data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : [] | ||
120 | }) | ||
121 | } | ||
122 | |||
123 | function sanitizeLocalUrl (url: string) { | ||
124 | if (!url) return '' | ||
125 | |||
126 | // Handle alternative channel URLs | ||
127 | return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/') | ||
128 | .replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/') | ||
129 | } | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { WEBSERVER } from '@server/initializers/constants' | ||
6 | import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' | ||
7 | import { Hooks } from '@server/lib/plugins/hooks' | ||
8 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
9 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | ||
10 | import { ResultList, Video } from '@shared/models' | ||
11 | import { VideosSearchQuery } from '../../../../shared/models/search' | ||
12 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' | ||
13 | import { logger } from '../../../helpers/logger' | ||
14 | import { getFormattedObjects } from '../../../helpers/utils' | ||
15 | import { | ||
16 | asyncMiddleware, | ||
17 | commonVideosFiltersValidator, | ||
18 | openapiOperationDoc, | ||
19 | optionalAuthenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSearchSort, | ||
23 | videosSearchSortValidator, | ||
24 | videosSearchValidator | ||
25 | } from '../../../middlewares' | ||
26 | import { VideoModel } from '../../../models/video/video' | ||
27 | import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models' | ||
28 | |||
29 | const searchVideosRouter = express.Router() | ||
30 | |||
31 | searchVideosRouter.get('/videos', | ||
32 | openapiOperationDoc({ operationId: 'searchVideos' }), | ||
33 | paginationValidator, | ||
34 | setDefaultPagination, | ||
35 | videosSearchSortValidator, | ||
36 | setDefaultSearchSort, | ||
37 | optionalAuthenticate, | ||
38 | commonVideosFiltersValidator, | ||
39 | videosSearchValidator, | ||
40 | asyncMiddleware(searchVideos) | ||
41 | ) | ||
42 | |||
43 | // --------------------------------------------------------------------------- | ||
44 | |||
45 | export { searchVideosRouter } | ||
46 | |||
47 | // --------------------------------------------------------------------------- | ||
48 | |||
49 | function searchVideos (req: express.Request, res: express.Response) { | ||
50 | const query: VideosSearchQuery = req.query | ||
51 | const search = query.search | ||
52 | |||
53 | if (isURISearch(search)) { | ||
54 | return searchVideoURI(search, res) | ||
55 | } | ||
56 | |||
57 | if (isSearchIndexSearch(query)) { | ||
58 | return searchVideosIndex(query, res) | ||
59 | } | ||
60 | |||
61 | return searchVideosDB(query, res) | ||
62 | } | ||
63 | |||
64 | async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) { | ||
65 | const result = await buildMutedForSearchIndex(res) | ||
66 | |||
67 | let body: VideosSearchQuery = Object.assign(query, result) | ||
68 | |||
69 | // Use the default instance NSFW policy if not specified | ||
70 | if (!body.nsfw) { | ||
71 | const nsfwPolicy = res.locals.oauth | ||
72 | ? res.locals.oauth.token.User.nsfwPolicy | ||
73 | : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY | ||
74 | |||
75 | body.nsfw = nsfwPolicy === 'do_not_list' | ||
76 | ? 'false' | ||
77 | : 'both' | ||
78 | } | ||
79 | |||
80 | body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params') | ||
81 | |||
82 | const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos' | ||
83 | |||
84 | try { | ||
85 | logger.debug('Doing videos search index request on %s.', url, { body }) | ||
86 | |||
87 | const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body }) | ||
88 | const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result') | ||
89 | |||
90 | return res.json(jsonResult) | ||
91 | } catch (err) { | ||
92 | logger.warn('Cannot use search index to make video search.', { err }) | ||
93 | |||
94 | return res.fail({ | ||
95 | status: HttpStatusCode.INTERNAL_SERVER_ERROR_500, | ||
96 | message: 'Cannot use search index to make video search' | ||
97 | }) | ||
98 | } | ||
99 | } | ||
100 | |||
101 | async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { | ||
102 | const apiOptions = await Hooks.wrapObject(Object.assign(query, { | ||
103 | includeLocalVideos: true, | ||
104 | nsfw: buildNSFWFilter(res, query.nsfw), | ||
105 | filter: query.filter, | ||
106 | user: res.locals.oauth ? res.locals.oauth.token.User : undefined | ||
107 | }), 'filter:api.search.videos.local.list.params') | ||
108 | |||
109 | const resultList = await Hooks.wrapPromiseFun( | ||
110 | VideoModel.searchAndPopulateAccountAndServer, | ||
111 | apiOptions, | ||
112 | 'filter:api.search.videos.local.list.result' | ||
113 | ) | ||
114 | |||
115 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
116 | } | ||
117 | |||
118 | async function searchVideoURI (url: string, res: express.Response) { | ||
119 | let video: MVideoAccountLightBlacklistAllFiles | ||
120 | |||
121 | // Check if we can fetch a remote video with the URL | ||
122 | if (isUserAbleToSearchRemoteURI(res)) { | ||
123 | try { | ||
124 | const syncParam = { | ||
125 | likes: false, | ||
126 | dislikes: false, | ||
127 | shares: false, | ||
128 | comments: false, | ||
129 | thumbnail: true, | ||
130 | refreshVideo: false | ||
131 | } | ||
132 | |||
133 | const result = await getOrCreateAPVideo({ videoObject: url, syncParam }) | ||
134 | video = result ? result.video : undefined | ||
135 | } catch (err) { | ||
136 | logger.info('Cannot search remote video %s.', url, { err }) | ||
137 | } | ||
138 | } else { | ||
139 | video = await VideoModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(url)) | ||
140 | } | ||
141 | |||
142 | return res.json({ | ||
143 | total: video ? 1 : 0, | ||
144 | data: video ? [ video.toFormattedJSON() ] : [] | ||
145 | }) | ||
146 | } | ||
147 | |||
148 | function sanitizeLocalUrl (url: string) { | ||
149 | if (!url) return '' | ||
150 | |||
151 | // Handle alternative video URLs | ||
152 | return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/') | ||
153 | } | ||