Compare commits

...

32 Commits

Author SHA1 Message Date
Chocobozzz 944afc977d
Improve channels search relevancy 2024-01-15 11:26:56 +01:00
Chocobozzz 06890d094e
Fix build 2024-01-15 11:13:09 +01:00
Chocobozzz b8db45dca1
Fix array filters 2024-01-15 11:12:00 +01:00
Chocobozzz 6a5563ed5b
Fix filters count 2024-01-15 11:00:32 +01:00
Chocobozzz c7edcd1839
Add ability to force index settings update 2024-01-15 10:24:55 +01:00
Chocobozzz 535b2b6594
Escape quotes in IN clause 2024-01-15 10:20:58 +01:00
Chocobozzz 3a180c1b3c
Fix README 2024-01-15 10:20:50 +01:00
Chocobozzz 8590598a78
Add missing playlists filterable attributes 2024-01-15 10:16:57 +01:00
Chocobozzz c74d6aa671
Update translations 2024-01-15 09:30:34 +01:00
josé m 8af619a214 Translated using Weblate (Galician)
Currently translated at 100.0% (123 of 123 strings)

Translation: PeerTube Search Index/client
Translate-URL: https://weblate.framasoft.org/projects/peertube-search-index/client/gl/
2024-01-15 09:28:15 +01:00
nexi 84177ed99a Translated using Weblate (Serbian (cyrillic))
Currently translated at 100.0% (123 of 123 strings)

Translation: PeerTube Search Index/client
Translate-URL: https://weblate.framasoft.org/projects/peertube-search-index/client/sr_Cyrl/
2024-01-15 09:28:15 +01:00
0que a0a49a4ee8 Translated using Weblate (Russian)
Currently translated at 100.0% (123 of 123 strings)

Translation: PeerTube Search Index/client
Translate-URL: https://weblate.framasoft.org/projects/peertube-search-index/client/ru/
2024-01-15 09:28:15 +01:00
Chocobozzz 6787428cd7
Add changelog 2024-01-15 09:27:12 +01:00
Chocobozzz a04dea2dd1
Add upgrade instructions 2024-01-15 09:23:32 +01:00
Chocobozzz aaad2ea75d
Fix removing non existing hosts 2024-01-09 11:06:15 +01:00
Chocobozzz bf63503e23
More logs 2024-01-09 10:44:05 +01:00
Chocobozzz 477fd984cf
Add no linguistic content to languages boost 2024-01-05 07:22:37 +01:00
Chocobozzz 65295a804a
Put some constants in config 2024-01-04 16:50:17 +01:00
Chocobozzz 491f936906
Increase bulk indexation time 2024-01-04 16:22:43 +01:00
Chocobozzz 9c43989b59
Fix bulk indexing 2024-01-04 15:37:00 +01:00
Chocobozzz 69e17e2777
Filter instance in bulk 2024-01-04 14:39:41 +01:00
Chocobozzz fde7007788
Try to have faster indexation 2024-01-04 14:20:20 +01:00
Chocobozzz c9f901d6a3
Merge branch 'master' into meilisearch 2023-12-20 16:31:34 +01:00
Chocobozzz b98aa43a77
Merge branch 'master' into meilisearch 2023-12-18 15:32:59 +01:00
Chocobozzz 603b3b31a6
Increase wait timeout 2023-11-13 11:18:06 +01:00
Chocobozzz 71e310f639
Wait index creaction 2023-11-13 11:06:50 +01:00
Chocobozzz 6d96cfab43
Merge branch 'master' into meilisearch 2023-11-13 11:01:32 +01:00
Chocobozzz 2edc24cae3
Reduce description indexation size 2023-11-13 11:01:17 +01:00
Chocobozzz 1a2d9ea921
Wait indexation 2023-11-13 10:15:10 +01:00
Chocobozzz 1c81808bdd
Merge branch 'master' into meilisearch 2023-11-10 15:17:57 +01:00
Chocobozzz d50775f88a
Fix build 2023-11-10 14:14:57 +01:00
Chocobozzz 7fb1b791ae
Move from elastic search to meilisearch 2023-11-10 10:16:12 +01:00
53 changed files with 1209 additions and 2169 deletions

10
CHANGELOG.md Normal file
View File

@ -0,0 +1,10 @@
# v1.0.0
* Use [Meilisearch](https://www.meilisearch.com) instead of Elastic Search
* See https://framagit.org/framasoft/peertube/search-index/-/issues/50 for more information
* Migration from Elastic Search is not implemented, just run another PeerTube search index to index videos/channels/playlists and when it's done you can switch from the old index and the new one
* Better global search relevancy
* Fix search with accentuated characters
* Improve search relevancy with non latin search
* Support verbatim search

View File

@ -7,12 +7,12 @@ $ git submodule update --init --recursive
$ yarn install --pure-lockfile
```
The database (Elastic Search) is automatically created by PeerTube at startup.
Indexes in Meilisearch are automatically created by PeerTube at startup.
Run simultaneously (for example with 3 terminals):
```terminal
$ tsc -w
$ npm run tsc -- -w
```
```terminal
@ -31,9 +31,11 @@ Add the locale in `client/src/main.ts` and `client/gettext.config.js` and run `n
## Production
### Installation
Install dependencies:
* NodeJS (v16)
* Elastic Search
* NodeJS (v18)
* MeiliSearch
```terminal
$ git clone https://framagit.org/framasoft/peertube/search-index.git /var/www/peertube-search-index
@ -45,18 +47,12 @@ $ vim config/production.yaml
$ NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube-search-index/config node dist/server.js
```
### Mapping migration
To update Elastic Search index mappings without downtime, run another instance of the search indexer
using the same configuration that the main node. You just have to update `elastic-search.indexes.*` to use new index names.
### Upgrade
```
$ cd /var/www/peertube-search-index
$ cp config/production.yaml config/production-1.yaml
$ vim config/production-1.yaml
$ NODE_ENV=production NODE_APP_INSTANCE=1 NODE_CONFIG_DIR=/var/www/peertube-search-index/config node dist/server.js
cd /var/www/peertube-search-index && \
git pull origin master && \
yarn install --pure-lockfile && \
npm run build && \
sudo systemctl restart peertube-search-index.service
```
After a while the new indexes will be filled. You can then stop the second indexer, update `config/production.yaml` to use
the new index names and restart the main index.

View File

@ -1,16 +1,16 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"PO-Revision-Date: 2023-01-06 09:38+0000\n"
"Last-Translator: josé m. <correoxm@disroot.org>\n"
"Language-Team: Galician <https://weblate.framasoft.org/projects/peertube-"
"search-index/client/gl/>\n"
"PO-Revision-Date: 2023-12-06 10:37+0000\n"
"Last-Translator: \"josé m.\" <correoxm@disroot.org>\n"
"Language-Team: Galician <https://weblate.framasoft.org/projects/"
"peertube-search-index/client/gl/>\n"
"Language: gl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.15\n"
"X-Generator: Weblate 5.2.1\n"
"Generated-By: easygettext\n"
#: src/views/Search.vue:64 src/views/Search.vue:65 src/views/Search.vue:68
@ -43,8 +43,8 @@ msgstr[1] "%{totalVideos} vídeos"
#: src/components/ChannelResult.vue:29
msgid "%{videosCount} video"
msgid_plural "%{videosCount} videos"
msgstr[0] "%{videosLength} vídeo"
msgstr[1] "%{videosLength} vídeos"
msgstr[0] "%{videosCount} vídeo"
msgstr[1] "%{videosCount} vídeos"
#: src/components/PlaylistResult.vue:118
msgid "%{videosLength} video"

View File

@ -1,45 +1,43 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"PO-Revision-Date: 2022-12-15 07:03+0000\n"
"Last-Translator: Дмитрий Кузнецов <dk65536@gmail.com>\n"
"Language-Team: Russian <https://weblate.framasoft.org/projects/peertube-"
"search-index/client/ru/>\n"
"PO-Revision-Date: 2023-10-30 11:37+0000\n"
"Last-Translator: 0que <0que@users.noreply.weblate.framasoft.org>\n"
"Language-Team: Russian <https://weblate.framasoft.org/projects/"
"peertube-search-index/client/ru/>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 4.14.1\n"
"X-Generator: Weblate 5.1.1\n"
"Generated-By: easygettext\n"
#: src/views/Search.vue:64 src/views/Search.vue:65 src/views/Search.vue:68
#: src/views/Search.vue:72
msgid "%{totalChannels} channel"
msgid_plural "%{totalChannels} channels"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
msgstr[0] "%{totalChannels} канал"
msgstr[1] "%{totalChannels} канала"
msgstr[2] "%{totalChannels} каналов"
#: src/views/Search.vue:84 src/views/Search.vue:85 src/views/Search.vue:88
#: src/views/Search.vue:96
msgid "%{totalPlaylists} playlist"
msgid_plural "%{totalPlaylists} playlists"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
msgstr[0] "%{totalChannels} плейлист"
msgstr[1] "%{totalChannels} плейлиста"
msgstr[2] "%{totalChannels} плейлистов"
#: src/views/Search.vue:38 src/views/Search.vue:39 src/views/Search.vue:42
#, fuzzy
msgid "%{totalResults} result found:"
msgid_plural "%{totalResults} results found:"
msgstr[0] "%{resultsCount} результат найден для"
msgstr[1] "%{resultsCount} результата найдено для"
msgstr[2] "%{resultsCount} результатов найдено для"
msgstr[0] "Найден %{resultsCount} результат:"
msgstr[1] "Найдено %{resultsCount} результата:"
msgstr[2] "Найдено %{resultsCount} результатов:"
#: src/views/Search.vue:44 src/views/Search.vue:45 src/views/Search.vue:48
#, fuzzy
msgid "%{totalVideos} video"
msgid_plural "%{totalVideos} videos"
msgstr[0] "%{videosLength} видео"
@ -48,7 +46,6 @@ msgstr[2] "%{videosLength} видео"
#: src/components/ChannelResult.vue:25 src/components/ChannelResult.vue:26
#: src/components/ChannelResult.vue:29
#, fuzzy
msgid "%{videosCount} video"
msgid_plural "%{videosCount} videos"
msgstr[0] "%{videosLength} видео"
@ -56,7 +53,6 @@ msgstr[1] "%{videosLength} видео"
msgstr[2] "%{videosLength} видео"
#: src/components/PlaylistResult.vue:118
#, fuzzy
msgid "%{videosLength} video"
msgid_plural "%{videosLength} videos"
msgstr[0] "%{videosLength} видео"
@ -64,28 +60,24 @@ msgstr[1] "%{videosLength} видео"
msgstr[2] "%{videosLength} видео"
#: src/components/SearchWarning.vue:7 src/components/SearchWarning.vue:8
#, fuzzy
msgid ""
"<strong>%{indexName}</strong> displays videos and channels that match your "
"search but is not the publisher, nor the owner."
msgstr ""
"<strong>%{indexName}</strong> показывает ролики и каналы, удовлетворяющие "
"вашему запросу. Поиск по издателям и владельцам не производится. Если вы "
"столкнулись с какими-то проблемами, уведомите администрацию PeerTube сайта, "
"на котором располагается видео."
"<strong>%{indexName}</strong> показывает ролики и каналы, соответствующие "
"вашему запросу, но не является их издателем или правообладателем."
#: src/components/Footer.vue:6 src/components/Footer.vue:7
msgid "A free software to take back control of your videos"
msgstr "Свободный софт позволяющий вам контролировать ваши видео"
msgstr "Свободное программное обеспечение, возвращающее вам контроль над видео"
#: src/components/Header.vue:17 src/components/Header.vue:18
#, fuzzy
msgid ""
"A search engine of <a class=\"peertube-link\" href=\"https://joinpeertube."
"org\" target=\"_blank\">PeerTube</a> videos, channels and playlists"
msgstr ""
"Поисковый движок для видео, каналов и плейлистов в <a href=\"https://"
"joinpeertube.org\" target=\"_blank\">PeerTube</a>"
"Поисковый движок для видео, каналов и плейлистов в <a class=\"peertube-link\""
" href=\"https://joinpeertube.org\" target=\"_blank\">PeerTube</a>"
#: src/components/Filters.vue:296
msgid "Activism"
@ -93,17 +85,16 @@ msgstr "Активизм"
#: src/components/Filters.vue:232
msgid "Add tag"
msgstr "Добавить тег"
msgstr "Введите тег здесь"
#: src/components/Filters.vue:151
msgid "All of these tags"
msgstr "Все эти теги"
msgstr "Содержит следующие теги"
#: src/components/PlaylistResult.vue:42 src/components/PlaylistResult.vue:43
#: src/components/PlaylistResult.vue:46
#, fuzzy
msgid "and updated on"
msgstr "Обновлено"
msgstr "и обновлено"
#: src/components/Filters.vue:298
msgid "Animals"
@ -111,7 +102,7 @@ msgstr "Животные"
#: src/components/Filters.vue:239 src/components/Filters.vue:264
msgid "Any"
msgstr "Любой"
msgstr "Любая"
#: src/components/Filters.vue:224
msgid "Apply filters"
@ -123,31 +114,31 @@ msgstr "Исскуство"
#: src/components/Filters.vue:306
msgid "Attribution"
msgstr "Атрибуция"
msgstr "Указание автора"
#: src/components/Filters.vue:308
msgid "Attribution - No Derivatives"
msgstr "Атрибуция - Без производных произведений"
msgstr "Указание автора - Без производных"
#: src/components/Filters.vue:309
msgid "Attribution - Non Commercial"
msgstr "Атрибуция - Некоммерческая"
msgstr "Указание автора - Некоммерческая"
#: src/components/Filters.vue:311
msgid "Attribution - Non Commercial - No Derivatives"
msgstr "Атрибуция - Некоммерческая - Без производных произведений"
msgstr "Указание автора - Некоммерческая - Без производных произведений"
#: src/components/Filters.vue:310
msgid "Attribution - Non Commercial - Share Alike"
msgstr "Атрибуция - Некоммерческая - Сохраняя условия"
msgstr "Указание автора - Некоммерческая - Сохраняя условия"
#: src/components/Filters.vue:307
msgid "Attribution - Share Alike"
msgstr "Атрибуция - Сохраняя условия"
msgstr "Указание автора - Сохраняя условия"
#: src/components/SortButton.vue:6 src/components/SortButton.vue:7
msgid "Best match"
msgstr "Лучшие совпадения"
msgstr "Лучшему совпадению"
#: src/components/Filters.vue:322
msgid "Català"
@ -168,11 +159,11 @@ msgstr "Изменить язык интерфейса"
#: src/components/ChannelResult.vue:29 src/components/ChannelResult.vue:30
#: src/components/ChannelResult.vue:33
msgid "Channel created on platform"
msgstr ""
msgstr "Канал создан на платформе"
#: src/components/Header.vue:7
msgid "Come back to homepage"
msgstr ""
msgstr "Вернуться на главную"
#: src/components/Filters.vue:291
msgid "Comedy"
@ -188,12 +179,12 @@ msgid "Deutsch"
msgstr "Немецкий"
#: src/components/Header.vue:25 src/components/Header.vue:26
#, fuzzy
msgid ""
"Developed by <a class=\"peertube-link\" href=\"https://framasoft.org\" "
"target=\"_blank\">Framasoft</a>"
msgstr ""
"Разработано <a href=\"https://framasoft.org\" target=\"_blank\">Framasoft</a>"
"Разработано <a class=\"peertube-link\" href=\"https://framasoft.org\" target="
"\"_blank\">Framasoft</a>"
#: src/components/ChannelResult.vue:83
msgid "Discover this channel on %{host}"
@ -201,32 +192,29 @@ msgstr "Посмотреть этот канал на %{host}"
#: src/components/Filters.vue:101 src/components/Filters.vue:102
msgid "Display all categories"
msgstr "Показывать все категории"
msgstr "Любая"
#: src/components/Filters.vue:139 src/components/Filters.vue:140
msgid "Display all languages"
msgstr "Показывать все языки"
msgstr "Любой"
#: src/components/Filters.vue:120 src/components/Filters.vue:121
msgid "Display all licenses"
msgstr "Показывать все лицензии"
msgstr "Любая"
#: src/views/Search.vue:75 src/views/Search.vue:76 src/views/Search.vue:79
#: src/views/Search.vue:83
#, fuzzy
msgid "Display more channels"
msgstr "Показывать только"
msgstr "Показать больше каналов"
#: src/views/Search.vue:107 src/views/Search.vue:95 src/views/Search.vue:96
#: src/views/Search.vue:99
#, fuzzy
msgid "Display more playlists"
msgstr "Показывать только"
msgstr "Показать большей плейлистов"
#: src/views/Search.vue:55 src/views/Search.vue:56 src/views/Search.vue:59
#, fuzzy
msgid "Display more videos"
msgstr "Показывать все категории"
msgstr "Показать больше видео"
#: src/components/Filters.vue:47 src/components/Filters.vue:48
msgid "Display only"
@ -234,7 +222,7 @@ msgstr "Показывать только"
#: src/components/Filters.vue:4 src/components/Filters.vue:5
msgid "Display sensitive content"
msgstr "Показывать чувствительный контент"
msgstr "Показывать небезопасные материалы"
#: src/components/Filters.vue:80 src/components/Filters.vue:81
msgid "Duration"
@ -275,7 +263,7 @@ msgstr "Фильмы"
#: src/components/Filters.vue:24 src/components/Filters.vue:25
msgid "Filter by result type"
msgstr ""
msgstr "Фильтровать по типу"
#: src/views/Search.vue:22 src/views/Search.vue:23
msgid "Filters"
@ -310,21 +298,18 @@ msgid "How To"
msgstr "Учебники"
#: src/components/SearchWarning.vue:15 src/components/SearchWarning.vue:16
#, fuzzy
msgid ""
"If you notice any problems with a video, report it to the administrators on "
"the PeerTube website where the video is published."
msgstr ""
"<strong>%{indexName}</strong> показывает ролики и каналы, удовлетворяющие "
"вашему запросу. Поиск по издателям и владельцам не производится. Если вы "
"столкнулись с какими-то проблемами, уведомите администрацию PeerTube сайта, "
"на котором располагается видео."
"Если с видео что-то не так, уведомите об этом администрацию сервера "
"PeerTube, на котором оно расположено."
#: src/components/PlaylistResult.vue:48 src/components/PlaylistResult.vue:49
#: src/components/PlaylistResult.vue:52 src/components/VideoResult.vue:37
#: src/components/VideoResult.vue:38 src/components/VideoResult.vue:41
msgid "In channel"
msgstr ""
msgstr "На канале"
#: src/components/Filters.vue:327
msgid "Italiano"
@ -332,7 +317,7 @@ msgstr "Итальянский"
#: src/components/Filters.vue:228 src/components/SearchInput.vue:59
msgid "Keyword, channel, video, playlist, etc."
msgstr "Ключевое слово, канал, видео, плейлист и т.д."
msgstr "Ключевое слово, канал, видео, плейлист и т.п."
#: src/components/Filters.vue:299
msgid "Kids"
@ -344,9 +329,8 @@ msgstr "Язык"
#: src/components/VideoResult.vue:49 src/components/VideoResult.vue:50
#: src/components/VideoResult.vue:53
#, fuzzy
msgid "Language:"
msgstr "Язык"
msgstr "Язык:"
#: src/components/Filters.vue:251
msgid "Last 30 days"
@ -362,15 +346,15 @@ msgstr "Последние 7 дней"
#: src/components/Footer.vue:11 src/components/Footer.vue:12
msgid "Learn more about PeerTube"
msgstr ""
msgstr "Узнайте больше про PeerTube"
#: src/components/SortButton.vue:10 src/components/SortButton.vue:11
msgid "Least recent"
msgstr "Наиболее свежие"
msgstr "Самые старые"
#: src/components/Footer.vue:15 src/components/Footer.vue:16
msgid "Legal notices"
msgstr "Юридическое уведомление"
msgstr "Юридическая информация"
#: src/components/Filters.vue:113
msgid "Licence"
@ -378,7 +362,7 @@ msgstr "Лицензия"
#: src/components/VideoResult.vue:6 src/components/VideoResult.vue:7
msgid "LIVE"
msgstr "ТРАНСЛЯЦИЯ"
msgstr "В ЭФИРЕ"
#: src/components/Filters.vue:55
msgid "Live videos"
@ -386,15 +370,15 @@ msgstr "Трансляции"
#: src/components/Filters.vue:276
msgid "Long (> 10 min)"
msgstr "Долго (> 10 мин)"
msgstr "Долгие (> 10 мин.)"
#: src/components/Filters.vue:272
msgid "Medium (4-10 min)"
msgstr "Средне (4-10 мин)"
msgstr "Средние (4-10 мин.)"
#: src/components/SortButton.vue:8 src/components/SortButton.vue:9
msgid "Most recent"
msgstr "Недавние"
msgstr "Новизне"
#: src/components/Filters.vue:283
msgid "Music"
@ -402,7 +386,7 @@ msgstr "Музыка"
#: src/components/SearchInput.vue:2 src/components/SearchInput.vue:3
msgid "My search"
msgstr ""
msgstr "Мой поиск"
#: src/components/Filters.vue:328
msgid "Nederlands"
@ -410,7 +394,7 @@ msgstr "Нидерландский"
#: src/components/Filters.vue:293
msgid "News & Politics"
msgstr "Новости & Политика"
msgstr "Новости и Политика"
#: src/components/Pagination.vue:19 src/components/Pagination.vue:20
msgid "Next page"
@ -421,9 +405,8 @@ msgid "No"
msgstr "Нет"
#: src/views/Search.vue:36 src/views/Search.vue:37 src/views/Search.vue:40
#, fuzzy
msgid "No results found."
msgstr "Результатов не найдено"
msgstr "Ничего не нашлось."
#: src/components/Filters.vue:330
msgid "Occitan"
@ -433,25 +416,23 @@ msgstr "Окситанский"
#: src/components/PlaylistResult.vue:58 src/components/VideoResult.vue:43
#: src/components/VideoResult.vue:44 src/components/VideoResult.vue:47
msgid "On platform"
msgstr ""
msgstr "На сервере"
#: src/components/Filters.vue:163
msgid "One of these tags"
msgstr "Один из тегов"
msgstr "Содержит хотя бы один из тегов"
#: src/components/Filters.vue:37
msgid "Only channels"
msgstr ""
msgstr "Только каналы"
#: src/components/Filters.vue:42
#, fuzzy
msgid "Only playlists"
msgstr "Создать плейлист"
msgstr "Только плейлисты"
#: src/components/Filters.vue:32
#, fuzzy
msgid "Only videos"
msgstr "Видео по запросу"
msgstr "Только видео"
#: src/components/Filters.vue:175
msgid "PeerTube instance"
@ -475,13 +456,12 @@ msgstr "Предыдущая страница"
#: src/components/Filters.vue:312
msgid "Public Domain Dedication"
msgstr "Достояние общественности"
msgstr "Общественное достояние"
#: src/components/VideoResult.vue:28 src/components/VideoResult.vue:29
#: src/components/VideoResult.vue:32
#, fuzzy
msgid "Published by"
msgstr "Дата публикации"
msgstr "Опубликовано"
#: src/components/Filters.vue:65 src/components/Filters.vue:66
msgid "Published date"
@ -506,26 +486,24 @@ msgid "Science & Technology"
msgstr "Наука & Технология"
#: src/views/Home.vue:15 src/views/Home.vue:16
#, fuzzy
msgid ""
"Search for your favorite videos, channels and playlists on <a "
"class=\"peertube-link\" href=\"%{indexedInstancesUrl}\" "
"target=\"_blank\">%{instancesCount} PeerTube websites</a> indexed by "
"%{indexName}!"
msgstr ""
"Ищите ваши любимые видео, каналы и плейлисты на <a "
"href=\"%{indexedInstancesUrl}\" target=\"_blank\">%{instancesCount} серверах "
"PeerTube</a>, проиндексированных %{indexName}!"
"Ищите ваши любимые видео, каналы и плейлисты на <a class=\"peertube-link\" "
"href=\"%{indexedInstancesUrl}\" target=\"_blank\">серверах PeerTube</a>, "
"проиндексированных %{indexName}!"
#: src/components/Footer.vue:13 src/components/Footer.vue:14
#, fuzzy
msgid "Search Index source code"
msgstr "Исходный код"
msgstr "Исходный код поисковика"
#: src/components/SearchInput.vue:13 src/components/SearchInput.vue:14
#: src/components/SearchInput.vue:25 src/components/SearchInput.vue:35
msgid "Search!"
msgstr ""
msgstr "Искать!"
#: src/components/Filters.vue:268
msgid "Short (< 4 min)"
@ -533,7 +511,7 @@ msgstr "Коротко (< 4 мин)"
#: src/components/SortButton.vue:3
msgid "Sort by:"
msgstr "Сортировать по:"
msgstr "Упорядочить по:"
#: src/components/Filters.vue:287
msgid "Sports"
@ -553,7 +531,7 @@ msgstr "Сегодня"
#: src/views/Search.vue:10 src/views/Search.vue:9
msgid "Toggle warning information"
msgstr ""
msgstr "Переключить предупреждение"
#: src/components/Filters.vue:288
msgid "Travels"
@ -569,7 +547,7 @@ msgstr "Видео по запросу"
#: src/components/PlaylistResult.vue:114
msgid "Watch the playlist on %{host}"
msgstr "Смотреть плейлист на %{host}"
msgstr "Открыть плейлист на %{host}"
#: src/components/VideoResult.vue:123
msgid "Watch the video on %{host}"

View File

@ -1,6 +1,6 @@
msgid ""
msgstr ""
"PO-Revision-Date: 2023-10-10 06:12+0000\n"
"PO-Revision-Date: 2023-11-15 01:52+0000\n"
"Last-Translator: nexi <nexiphotographer@gmail.com>\n"
"Language-Team: Serbian (cyrillic) <https://weblate.framasoft.org/projects/"
"peertube-search-index/client/sr_Cyrl/>\n"
@ -9,7 +9,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.0.1\n"
"X-Generator: Weblate 5.1.1\n"
#: src/views/Search.vue:64
#: src/views/Search.vue:65
@ -182,7 +182,7 @@ msgstr "Комедија"
#: src/components/PlaylistResult.vue:40
#: src/components/PlaylistResult.vue:43
msgid "Created by"
msgstr "Направљено од стране"
msgstr "Направио:"
#: src/components/Filters.vue:326
msgid "Deutsch"
@ -192,8 +192,8 @@ msgstr "немачки"
#: src/components/Header.vue:26
msgid "Developed by <a class=\"peertube-link\" href=\"https://framasoft.org\" target=\"_blank\">Framasoft</a>"
msgstr ""
"Развијено од стране <a class=\"peertube-link\" href=\"https://framasoft.org\""
" target=\"_blank\">Framasoft</a>"
"Развио: <a class=\"peertube-link\" href=\"https://framasoft.org\" target="
"\"_blank\">Framasoft</a>"
#: src/components/ChannelResult.vue:83
msgid "Discover this channel on %{host}"
@ -504,7 +504,7 @@ msgstr "Посвета у јавном власништву"
#: src/components/VideoResult.vue:29
#: src/components/VideoResult.vue:32
msgid "Published by"
msgstr "Објављено од стране"
msgstr "Објавио:"
#: src/components/Filters.vue:65
#: src/components/Filters.vue:66

View File

@ -1,8 +1,8 @@
import axios from 'axios'
import { ResultList, VideoChannelsSearchQuery, VideoPlaylistsSearchQuery, VideosSearchQuery } from '@peertube/peertube-types'
import { EnhancedVideoChannel } from '../../../server/types/channel.model'
import { EnhancedPlaylist } from '../../../server/types/playlist.model'
import { EnhancedVideo } from '../../../server/types/video.model'
import { APIVideoChannel } from '../../../server/types/channel.model'
import { APIPlaylist } from '../../../server/types/playlist.model'
import { APIVideo } from '../../../server/types/video.model'
import { buildApiUrl } from './utils'
const baseVideosPath = '/api/v1/search/videos'
@ -18,7 +18,7 @@ function searchVideos (options: VideosSearchQuery) {
}
}
return axios.get<ResultList<EnhancedVideo>>(buildApiUrl(baseVideosPath), axiosOptions)
return axios.get<ResultList<APIVideo>>(buildApiUrl(baseVideosPath), axiosOptions)
.then(res => res.data)
}
@ -31,7 +31,7 @@ function searchVideoChannels (options: VideoChannelsSearchQuery) {
}
}
return axios.get<ResultList<EnhancedVideoChannel>>(buildApiUrl(baseVideoChannelsPath), axiosOptions)
return axios.get<ResultList<APIVideoChannel>>(buildApiUrl(baseVideoChannelsPath), axiosOptions)
.then(res => res.data)
}
@ -44,7 +44,7 @@ function searchVideoPlaylists (options: VideoPlaylistsSearchQuery) {
}
}
return axios.get<ResultList<EnhancedPlaylist>>(buildApiUrl(baseVideoPlaylistsPath), axiosOptions)
return axios.get<ResultList<APIPlaylist>>(buildApiUrl(baseVideoPlaylistsPath), axiosOptions)
.then(res => res.data)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -133,14 +133,14 @@
import { getConfig } from '../shared/config'
import { pageToAPIParams, durationRangeToAPIParams, publishedDateRangeToAPIParams, extractTagsFromQuery, extractQueryToIntArray, extractQueryToStringArray, extractQueryToInt, extractQueryToBoolean } from '../shared/utils'
import { SearchUrl } from '../models'
import { EnhancedVideo } from '../../../server/types/video.model'
import { EnhancedVideoChannel } from '../../../server/types/channel.model'
import { APIVideo } from '../../../server/types/video.model'
import { APIVideoChannel } from '../../../server/types/channel.model'
import Pagination from '../components/Pagination.vue'
import SearchInput from '../components/SearchInput.vue'
import SortButton from '../components/SortButton.vue'
import { VideoChannelsSearchQuery, ResultList, VideosSearchQuery } from '@peertube/peertube-types'
import type { VideoChannelsSearchQuery, ResultList, VideosSearchQuery } from '@peertube/peertube-types'
import Nprogress from 'nprogress'
import { EnhancedPlaylist } from '../../../server/types/playlist.model'
import { APIPlaylist } from '../../../server/types/playlist.model'
import { PlaylistsSearchQuery } from '../../../server/types/search-query/playlist-search.model'
export default defineComponent({
@ -166,13 +166,13 @@
totalResults: null as number,
totalVideos: null as number,
videos: null as EnhancedVideo[],
videos: null as APIVideo[],
totalChannels: null as number,
channels: null as EnhancedVideoChannel[],
channels: null as APIVideoChannel[],
totalPlaylists: null as number,
playlists: null as EnhancedPlaylist[],
playlists: null as APIPlaylist[],
currentPage: 1,
pages: [],
@ -295,8 +295,8 @@
if (query.languageOneOf) count++
if (query.isLive) count++
if (query.resultType) count++
if (Array.isArray(query.tagsAllOf) && query.tagsAllOf.length !== 0) count++
if (Array.isArray(query.tagsOneOf) && query.tagsOneOf.length !== 0) count++
if (extractQueryToStringArray(query.tagsAllOf) && query.tagsAllOf.length !== 0) count++
if (extractQueryToStringArray(query.tagsOneOf) && query.tagsOneOf.length !== 0) count++
return count
},
@ -385,7 +385,7 @@
} as PlaylistsSearchQuery
},
searchVideos (): Promise<ResultList<EnhancedVideo>> {
searchVideos (): Promise<ResultList<APIVideo>> {
if (this.isVideoSearchDisabled()) {
return Promise.resolve({ total: 0, data: [] })
}
@ -395,7 +395,7 @@
return searchVideos(query)
},
searchChannels (): Promise<ResultList<EnhancedVideoChannel>> {
searchChannels (): Promise<ResultList<APIVideoChannel>> {
if (this.isChannelSearchDisabled()) {
return Promise.resolve({ data: [], total: 0 })
}
@ -405,7 +405,7 @@
return searchVideoChannels(query)
},
searchPlaylists (): Promise<ResultList<EnhancedPlaylist>> {
searchPlaylists (): Promise<ResultList<APIPlaylist>> {
if (this.isPlaylistSearchDisabled()) {
return Promise.resolve({ data: [], total: 0 })
}

View File

@ -7,22 +7,20 @@ webserver:
hostname: 'localhost'
port: 3234
elastic-search:
# https or http
http: 'http'
auth:
username: null
password: null
ssl:
# Specificy a custom CA
ca: null
hostname: 'localhost'
port: 9200
meilisearch:
host: 'http://127.0.0.1:7700'
api_key: null
indexes:
videos: 'peertube-index-videos'
channels: 'peertube-index-channels'
playlists: 'peertube-index-playlists'
# Avoid updating index settings at startup
# Set to true when a new release of the search index updates index settings
force_settings_update_at_startup: false
log:
level: 'debug' # debug/info/warning/error
@ -54,91 +52,16 @@ instances-index:
enabled: false
hosts: null
videos-search:
# Allow client to send browser language to boost results score that are in these languages
boost-languages:
enabled: true
# Add ability to change videos search fields boost and match value
# See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html for more information
#
# If boost == 0, the field will not be part of the search
#
# match_type could be 'default' or 'phrase'
# * default: use default Elastic Search match query, including fuzziness
# * phrase: use Elastic Search phrase match query
search-fields:
uuid:
boost: 100
match_type: 'default'
short-uuid:
boost: 100
match_type: 'default'
name:
boost: 5
match_type: 'default'
description:
boost: 1
match_type: 'phrase'
tags:
boost: 2
match_type: 'default'
account-display-name:
boost: 2
match_type: 'default'
channel-display-name:
boost: 2
match_type: 'default'
channels-search:
# Add ability to change channels search fields boost and match value
# See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html for more information
#
# If boost == 0, the field will not be part of the search
#
# match_type could be 'default' or 'phrase'
# * default: use default Elastic Search match query, including fuzziness
# * phrase: use Elastic Search phrase match query
search-fields:
name:
boost: 5
match_type: 'default'
description:
boost: 1
match_type: 'phrase'
display-name:
boost: 3
match_type: 'default'
account-display-name:
boost: 2
match_type: 'default'
playlists-search:
# Add ability to change playlists search fields boost and match value
# See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html for more information
#
# If boost == 0, the field will not be part of the search
#
# match_type could be 'default' or 'phrase'
# * default: use default Elastic Search match query, including fuzziness
# * phrase: use Elastic Search phrase match query
search-fields:
uuid:
boost: 100
match_type: 'default'
short-uuid:
boost: 100
match_type: 'default'
display-name:
boost: 5
match_type: 'default'
description:
boost: 1
match_type: 'phrase'
api:
# Blacklist hosts that will not be returned by the search API
blacklist:
enabled: false
# Array of hosts
hosts: null
indexer:
# How many hosts in parallel to index
host_concurrency: 10
# How much time to wait before bulk indexing in Meilisearch data
bulk_indexation_interval_ms: 10000

View File

@ -1,8 +1,13 @@
elastic-search:
meilisearch:
# You can put this key as master key when starting meilisearch
api_key: '4kMeZtP0QsgE3QCDSEMYUt_WFusGjq5JgOc9atujpKw'
indexes:
videos: 'peertube-index-videos-test1'
channels: 'peertube-index-channels-test1'
playlists: 'peertube-index-playlists-test1'
videos: 'peertube-index-videos-test2'
channels: 'peertube-index-channels-test2'
playlists: 'peertube-index-playlists-test2'
force_settings_update_at_startup: true
search-instance:
name_image: '/theme/framasoft/img/title.svg'
@ -25,9 +30,9 @@ instances-index:
- 'thinkerview.video'
- 'replay.jres.org'
- 'tube.nah.re'
- 'peertube.parleur.net'
- 'video.passageenseine.fr'
- 'exode.me'
- 'peertube.luga.at'
- 'peertube.ch'
api:
blacklist:

View File

@ -22,7 +22,6 @@
"i18n:update": "cd client && git fetch weblate && git merge weblate/master && npm run gettext:extract && npm run gettext:compile"
},
"dependencies": {
"@elastic/elasticsearch": "^8.2.1",
"async": "^3.2.3",
"bluebird": "^3.5.3",
"body-parser": "^1.20.0",
@ -33,6 +32,7 @@
"fs-extra": "^11.1.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.15",
"meilisearch": "^0.35.0",
"mkdirp": "^1.0.4",
"morgan": "^1.9.1",
"multer": "^1.4.5-lts.1",
@ -59,7 +59,6 @@
"@types/node": "^18.11.15",
"@types/pino": "^7.0.5",
"@types/request": "^2.48.8",
"@types/sequelize": "^4.28.13",
"@types/validator": "^13.7.2",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",

View File

@ -149,15 +149,15 @@ app.use(function (err, req, res, next) {
app.listen(CONFIG.LISTEN.PORT, async () => {
logger.info('Server listening on port %d', CONFIG.LISTEN.PORT)
IndexationScheduler.Instance.enable()
try {
await IndexationScheduler.Instance.initIndexes()
} catch (err) {
logger.error(err, 'Cannot init videos index.')
logger.error(err, 'Cannot init indexes.')
process.exit(-1)
}
IndexationScheduler.Instance.enable()
IndexationScheduler.Instance.execute()
.catch(err => logger.error(err, 'Cannot run video indexer'))
})

View File

@ -1,6 +1,6 @@
import express from 'express'
import { Searcher } from '../../lib/controllers/searcher'
import { formatChannelForAPI, queryChannels } from '../../lib/elastic-search/elastic-search-channels'
import { formatChannelForAPI, queryChannels } from '../../lib/meilisearch/meilisearch-channels'
import { asyncMiddleware } from '../../middlewares/async'
import { setDefaultPagination } from '../../middlewares/pagination'
import { setDefaultSearchSort } from '../../middlewares/sort'

View File

@ -1,6 +1,6 @@
import express from 'express'
import { Searcher } from '../../lib/controllers/searcher'
import { formatPlaylistForAPI, queryPlaylists } from '../../lib/elastic-search/elastic-search-playlists'
import { formatPlaylistForAPI, queryPlaylists } from '../../lib/meilisearch/meilisearch-playlists'
import { asyncMiddleware } from '../../middlewares/async'
import { setDefaultPagination } from '../../middlewares/pagination'
import { setDefaultSearchSort } from '../../middlewares/sort'

View File

@ -1,6 +1,6 @@
import express from 'express'
import { Searcher } from '../../lib/controllers/searcher'
import { formatVideoForAPI, queryVideos } from '../../lib/elastic-search/elastic-search-videos'
import { formatVideoForAPI, queryVideos } from '../../lib/meilisearch/meilisearch-videos'
import { asyncMiddleware } from '../../middlewares/async'
import { setDefaultPagination } from '../../middlewares/pagination'
import { setDefaultSearchSort } from '../../middlewares/sort'

View File

@ -1,30 +0,0 @@
import { readFileSync } from 'fs-extra'
import { Client } from '@elastic/elasticsearch'
import { CONFIG } from '../initializers/constants'
const elasticOptions = {
node: CONFIG.ELASTIC_SEARCH.HTTP + '://' + CONFIG.ELASTIC_SEARCH.HOSTNAME + ':' + CONFIG.ELASTIC_SEARCH.PORT
}
if (CONFIG.ELASTIC_SEARCH.SSL.CA) {
Object.assign(elasticOptions, {
tls: {
ca: readFileSync(CONFIG.ELASTIC_SEARCH.SSL.CA)
}
})
}
if (CONFIG.ELASTIC_SEARCH.AUTH.USERNAME) {
Object.assign(elasticOptions, {
auth: {
username: CONFIG.ELASTIC_SEARCH.AUTH.USERNAME,
password: CONFIG.ELASTIC_SEARCH.AUTH.PASSWORD
}
})
}
const elasticSearch = new Client(elasticOptions)
export {
elasticSearch
}

View File

@ -0,0 +1,9 @@
import { MeiliSearch } from 'meilisearch'
import { CONFIG } from '../initializers/constants'
const client = new MeiliSearch({
host: CONFIG.MEILISEARCH.HOST,
apiKey: CONFIG.MEILISEARCH.API_KEY
})
export { client }

View File

@ -12,22 +12,15 @@ const CONFIG = {
HOSTNAME: config.get<string>('webserver.hostname'),
PORT: config.get<number>('webserver.port')
},
ELASTIC_SEARCH: {
HTTP: config.get<string>('elastic-search.http'),
AUTH: {
USERNAME: config.get<string>('elastic-search.auth.username'),
PASSWORD: config.get<string>('elastic-search.auth.password')
},
SSL: {
CA: config.get<string>('elastic-search.ssl.ca')
},
HOSTNAME: config.get<string>('elastic-search.hostname'),
PORT: config.get<number>('elastic-search.port'),
MEILISEARCH: {
HOST: config.get<string>('meilisearch.host'),
API_KEY: config.get<string>('meilisearch.api_key'),
INDEXES: {
VIDEOS: config.get<string>('elastic-search.indexes.videos'),
CHANNELS: config.get<string>('elastic-search.indexes.channels'),
PLAYLISTS: config.get<string>('elastic-search.indexes.playlists')
}
VIDEOS: config.get<string>('meilisearch.indexes.videos'),
CHANNELS: config.get<string>('meilisearch.indexes.channels'),
PLAYLISTS: config.get<string>('meilisearch.indexes.playlists')
},
FORCE_SETTINGS_UPDATE_AT_STARTUP: config.get<boolean>('meilisearch.force_settings_update_at_startup')
},
LOG: {
LEVEL: config.get<string>('log.level')
@ -40,96 +33,6 @@ const CONFIG = {
LEGAL_NOTICES_URL: config.get<string>('search-instance.legal_notices_url'),
THEME: config.get<string>('search-instance.theme')
},
VIDEOS_SEARCH: {
BOOST_LANGUAGES: {
ENABLED: config.get<boolean>('videos-search.boost-languages.enabled')
},
SEARCH_FIELDS: {
UUID: {
FIELD_NAME: 'uuid',
BOOST: config.get<number>('videos-search.search-fields.uuid.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.uuid.match_type')
},
SHORT_UUID: {
FIELD_NAME: 'shortUUID',
BOOST: config.get<number>('videos-search.search-fields.short-uuid.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.short-uuid.match_type')
},
NAME: {
FIELD_NAME: 'name',
BOOST: config.get<number>('videos-search.search-fields.name.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.name.match_type')
},
DESCRIPTION: {
FIELD_NAME: 'description',
BOOST: config.get<number>('videos-search.search-fields.description.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.description.match_type')
},
TAGS: {
FIELD_NAME: 'tags',
BOOST: config.get<number>('videos-search.search-fields.tags.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.tags.match_type')
},
ACCOUNT_DISPLAY_NAME: {
FIELD_NAME: 'account.displayName',
BOOST: config.get<number>('videos-search.search-fields.account-display-name.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.account-display-name.match_type')
},
CHANNEL_DISPLAY_NAME: {
FIELD_NAME: 'channel.displayName',
BOOST: config.get<number>('videos-search.search-fields.channel-display-name.boost'),
MATCH_TYPE: config.get<string>('videos-search.search-fields.channel-display-name.match_type')
}
}
},
CHANNELS_SEARCH: {
SEARCH_FIELDS: {
NAME: {
FIELD_NAME: 'name',
BOOST: config.get<number>('channels-search.search-fields.name.boost'),
MATCH_TYPE: config.get<string>('channels-search.search-fields.name.match_type')
},
DESCRIPTION: {
FIELD_NAME: 'description',
BOOST: config.get<number>('channels-search.search-fields.description.boost'),
MATCH_TYPE: config.get<string>('channels-search.search-fields.description.match_type')
},
DISPLAY_NAME: {
FIELD_NAME: 'displayName',
BOOST: config.get<number>('channels-search.search-fields.display-name.boost'),
MATCH_TYPE: config.get<string>('channels-search.search-fields.display-name.match_type')
},
ACCOUNT_DISPLAY_NAME: {
FIELD_NAME: 'ownerAccount.displayName',
BOOST: config.get<number>('channels-search.search-fields.account-display-name.boost'),
MATCH_TYPE: config.get<string>('channels-search.search-fields.account-display-name.match_type')
}
}
},
PLAYLISTS_SEARCH: {
SEARCH_FIELDS: {
UUID: {
FIELD_NAME: 'uuid',
BOOST: config.get<number>('playlists-search.search-fields.uuid.boost'),
MATCH_TYPE: config.get<string>('playlists-search.search-fields.uuid.match_type')
},
SHORT_UUID: {
FIELD_NAME: 'shortUUID',
BOOST: config.get<number>('playlists-search.search-fields.short-uuid.boost'),
MATCH_TYPE: config.get<string>('playlists-search.search-fields.short-uuid.match_type')
},
DISPLAY_NAME: {
FIELD_NAME: 'displayName',
BOOST: config.get<number>('playlists-search.search-fields.display-name.boost'),
MATCH_TYPE: config.get<string>('playlists-search.search-fields.display-name.match_type')
},
DESCRIPTION: {
FIELD_NAME: 'description',
BOOST: config.get<number>('playlists-search.search-fields.description.boost'),
MATCH_TYPE: config.get<string>('playlists-search.search-fields.description.match_type')
}
}
},
INSTANCES_INDEX: {
URL: config.get<string>('instances-index.url'),
PUBLIC_URL: config.get<string>('instances-index.public_url'),
@ -143,13 +46,17 @@ const CONFIG = {
ENABLED: config.get<boolean>('api.blacklist.enabled'),
HOSTS: config.get<string[]>('api.blacklist.hosts')
}
},
INDEXER: {
HOST_CONCURRENCY: config.get<number>('indexer.host_concurrency'),
BULK_INDEXATION_INTERVAL_MS: config.get<number>('indexer.bulk_indexation_interval_ms')
}
}
const SORTABLE_COLUMNS = {
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ]
VIDEOS_SEARCH: [ '_rankingScore', 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes' ],
CHANNELS_SEARCH: [ '_rankingScore', 'match', 'displayName', 'createdAt' ],
PLAYLISTS_SEARCH: [ '_rankingScore', 'match', 'displayName', 'createdAt' ]
}
const PAGINATION_START = {
@ -165,10 +72,9 @@ const SCHEDULER_INTERVALS_MS = {
indexation: 60000 * 60 * 24 // 24 hours
}
const INDEXER_COUNT = 10
const INDEXER_COUNT = 20
const INDEXER_LIMIT = 500000
const INDEXER_HOST_CONCURRENCY = 3
const INDEXER_QUEUE_CONCURRENCY = 3
const REQUESTS = {
@ -176,17 +82,6 @@ const REQUESTS = {
WAIT: 10000 // 10 seconds
}
const ELASTIC_SEARCH_QUERY = {
FUZZINESS: 'AUTO:4,7',
OPERATOR: 'OR',
MINIMUM_SHOULD_MATCH: '3<75%',
BOOST_LANGUAGE_VALUE: 1,
MALUS_LANGUAGE_VALUE: 0.5,
VIDEOS_MULTI_MATCH_FIELDS: buildMatchFieldConfig(CONFIG.VIDEOS_SEARCH.SEARCH_FIELDS),
CHANNELS_MULTI_MATCH_FIELDS: buildMatchFieldConfig(CONFIG.CHANNELS_SEARCH.SEARCH_FIELDS),
PLAYLISTS_MULTI_MATCH_FIELDS: buildMatchFieldConfig(CONFIG.PLAYLISTS_SEARCH.SEARCH_FIELDS)
}
function getWebserverUrl () {
if (CONFIG.WEBSERVER.PORT === 80 || CONFIG.WEBSERVER.PORT === 443) {
return CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME
@ -195,28 +90,6 @@ function getWebserverUrl () {
return CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
}
function buildMatchFieldConfig (fields: { [name: string]: { BOOST: number, FIELD_NAME: string, MATCH_TYPE: string } }) {
const selectFields = (matchType: 'phrase' | 'default') => {
return Object.keys(fields)
.filter(fieldName => fields[fieldName].MATCH_TYPE === matchType)
.map(fieldName => fields[fieldName])
}
const buildMultiMatch = (fields: { BOOST: number, FIELD_NAME: string }[]) => {
return fields.map(fieldObj => {
if (fieldObj.BOOST <= 0) return ''
return `${fieldObj.FIELD_NAME}^${fieldObj.BOOST}`
})
.filter(v => !!v)
}
return {
default: buildMultiMatch(selectFields('default')),
phrase: buildMultiMatch(selectFields('phrase'))
}
}
if (isTestInstance()) {
SCHEDULER_INTERVALS_MS.indexation = 1000 * 60 * 5 // 5 minutes
}
@ -231,9 +104,7 @@ export {
SORTABLE_COLUMNS,
INDEXER_QUEUE_CONCURRENCY,
SCHEDULER_INTERVALS_MS,
INDEXER_HOST_CONCURRENCY,
INDEXER_COUNT,
INDEXER_LIMIT,
REQUESTS,
ELASTIC_SEARCH_QUERY
REQUESTS
}

View File

@ -1,209 +0,0 @@
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { elasticSearch } from '../../helpers/elastic-search'
import { logger } from '../../helpers/logger'
import { CONFIG, ELASTIC_SEARCH_QUERY } from '../../initializers/constants'
import { DBChannel, EnhancedVideoChannel, IndexableChannel } from '../../types/channel.model'
import { ChannelsSearchQuery } from '../../types/search-query/channel-search.model'
import { buildSort, extractSearchQueryResult } from './elastic-search-queries'
import { buildChannelOrAccountCommonMapping, buildMultiMatchBool } from './shared'
import {
formatActorImageForAPI,
formatActorImageForDB,
formatActorImagesForAPI,
formatActorImagesForDB
} from './shared/elastic-search-avatar'
async function queryChannels (search: ChannelsSearchQuery) {
const bool: any = {}
const mustNot: any[] = []
const filter: any[] = []
if (search.search) {
Object.assign(bool, buildMultiMatchBool(search.search, ELASTIC_SEARCH_QUERY.CHANNELS_MULTI_MATCH_FIELDS))
}
if (search.blockedAccounts) {
mustNot.push({
terms: {
'ownerAccount.handle': search.blockedAccounts
}
})
}
if (search.blockedHosts) {
mustNot.push({
terms: {
host: search.blockedHosts
}
})
}
mustNot.push({
term: {
videosCount: 0
}
})
if (search.host) {
filter.push({
term: {
host: search.host
}
})
}
if (search.handles) {
filter.push({
terms: {
handle: search.handles
}
})
}
if (filter.length !== 0) {
Object.assign(bool, { filter })
}
if (mustNot.length !== 0) {
Object.assign(bool, { must_not: mustNot })
}
const body = {
from: search.start,
size: search.count,
sort: buildSort(search.sort),
query: { bool }
}
logger.debug({ body }, 'Will query Elastic Search for channels.')
const res = await elasticSearch.search({
index: CONFIG.ELASTIC_SEARCH.INDEXES.CHANNELS,
body
})
return extractSearchQueryResult(res)
}
function formatChannelForAPI (c: DBChannel, fromHost?: string): EnhancedVideoChannel {
return {
id: c.id,
score: c.score,
url: c.url,
name: c.name,
host: c.host,
followingCount: c.followingCount,
followersCount: c.followersCount,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
avatar: formatActorImageForAPI(c.avatar),
avatars: formatActorImagesForAPI(c.avatars, c.avatar),
banner: formatActorImageForAPI(c.banner),
banners: formatActorImagesForAPI(c.banners, c.banner),
displayName: c.displayName,
description: c.description,
support: c.support,
isLocal: fromHost === c.host,
videosCount: c.videosCount || 0,
ownerAccount: {
id: c.ownerAccount.id,
url: c.ownerAccount.url,
displayName: c.ownerAccount.displayName,
description: c.ownerAccount.description,
name: c.ownerAccount.name,
host: c.ownerAccount.host,
followingCount: c.ownerAccount.followingCount,
followersCount: c.ownerAccount.followersCount,
createdAt: c.ownerAccount.createdAt,
updatedAt: c.ownerAccount.updatedAt,
avatar: formatActorImageForAPI(c.ownerAccount.avatar),
avatars: formatActorImagesForAPI(c.ownerAccount.avatars, c.ownerAccount.avatar)
}
}
}
function formatChannelForDB (c: IndexableChannel): DBChannel {
return {
id: c.id,
name: c.name,
host: c.host,
url: c.url,
avatar: formatActorImageForDB(c.avatar, c.host),
avatars: formatActorImagesForDB(c.avatars, c.host),
banner: formatActorImageForDB(c.banner, c.host),
banners: formatActorImagesForDB(c.banners, c.host),
displayName: c.displayName,
indexedAt: new Date(),
followingCount: c.followingCount,
followersCount: c.followersCount,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
description: c.description,
support: c.support,
videosCount: c.videosCount,
handle: `${c.name}@${c.host}`,
ownerAccount: {
id: c.ownerAccount.id,
url: c.ownerAccount.url,
displayName: c.ownerAccount.displayName,
description: c.ownerAccount.description,
name: c.ownerAccount.name,
host: c.ownerAccount.host,
followingCount: c.ownerAccount.followingCount,
followersCount: c.ownerAccount.followersCount,
createdAt: c.ownerAccount.createdAt,
updatedAt: c.ownerAccount.updatedAt,
handle: `${c.ownerAccount.name}@${c.ownerAccount.host}`,
avatar: formatActorImageForDB(c.ownerAccount.avatar, c.ownerAccount.host),
avatars: formatActorImagesForDB(c.ownerAccount.avatars, c.ownerAccount.host)
}
}
}
function buildChannelsMapping () {
const base = buildChannelOrAccountCommonMapping()
Object.assign(base, {
videosCount: {
type: 'long'
},
support: {
type: 'keyword'
},
ownerAccount: {
properties: buildChannelOrAccountCommonMapping()
}
} as Record<PropertyName, MappingProperty>)
return base
}
export {
buildChannelsMapping,
formatChannelForDB,
formatChannelForAPI,
queryChannels
}

View File

@ -1,91 +0,0 @@
import { flatMap } from 'lodash'
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { elasticSearch } from '../../helpers/elastic-search'
import { logger } from '../../helpers/logger'
import { IndexableDoc } from '../../types/indexable-doc.model'
function buildIndex (name: string, mapping: Record<PropertyName, MappingProperty>) {
logger.info('Initialize %s Elastic Search index.', name)
return elasticSearch.indices.create({
index: name,
body: {
settings: {
number_of_shards: 1,
number_of_replicas: 1
},
mappings: {
properties: mapping
}
}
}).catch(err => {
if (err.name === 'ResponseError' && err.meta?.body?.error.root_cause[0]?.type === 'resource_already_exists_exception') return
throw err
})
}
async function indexDocuments <T extends IndexableDoc> (options: {
objects: T[]
formatter: (o: T) => any
replace: boolean
index: string
}) {
const { objects, formatter, replace, index } = options
const elIdIndex: { [elId: string]: T } = {}
for (const object of objects) {
elIdIndex[object.elasticSearchId] = object
}
const method = replace ? 'index' : 'update'
const body = flatMap(objects, v => {
const doc = formatter(v)
const options = replace
? doc
: { doc, doc_as_upsert: true }
return [
{
[method]: {
_id: v.elasticSearchId,
_index: index
}
},
options
]
})
const result = await elasticSearch.bulk({
index,
body
})
if (result.errors === true) {
const msg = 'Cannot insert data in elastic search.'
logger.error({ err: result }, msg)
throw new Error(msg)
}
const created: T[] = result.items
.map(i => i[method])
.filter(i => i.result === 'created')
.map(i => elIdIndex[i._id])
return { created }
}
function refreshIndex (indexName: string) {
logger.info('Refreshing %s index.', indexName)
return elasticSearch.indices.refresh({ index: indexName })
}
export {
buildIndex,
indexDocuments,
refreshIndex
}

View File

@ -1,74 +0,0 @@
import { AggregationsStringTermsAggregate } from '@elastic/elasticsearch/lib/api/types'
import { elasticSearch } from '../../helpers/elastic-search'
import { CONFIG } from '../../initializers/constants'
import { getMajorInstanceVersion, listIndexInstancesHost } from '../requests/instances-index'
import { extractBucketsFromAggregation } from './elastic-search-queries'
import { logger } from '../../helpers/logger'
async function buildInstanceHosts () {
let indexHosts = await listIndexInstancesHost()
if (CONFIG.INSTANCES_INDEX.WHITELIST.ENABLED) {
const whitelistHosts = Array.isArray(CONFIG.INSTANCES_INDEX.WHITELIST.HOSTS)
? CONFIG.INSTANCES_INDEX.WHITELIST.HOSTS
: []
indexHosts = indexHosts.filter(h => whitelistHosts.includes(h))
}
for (const indexHost of indexHosts) {
const instanceVersion = await getMajorInstanceVersion(indexHost)
if (instanceVersion < 4) {
logger.info(`Do not index ${indexHost} because the major version is too low (v${instanceVersion} < v4)`)
indexHosts = indexHosts.filter(h => h !== indexHost)
}
}
const dbHosts = await listDBInstances()
const removedHosts = getRemovedHosts(dbHosts, indexHosts)
return { indexHosts, removedHosts }
}
export {
buildInstanceHosts
}
// ##################################################
async function listDBInstances () {
const setResult = new Set<string>()
const indexes = [
CONFIG.ELASTIC_SEARCH.INDEXES.VIDEOS,
CONFIG.ELASTIC_SEARCH.INDEXES.CHANNELS
]
for (const index of indexes) {
const res = await elasticSearch.search<unknown, Record<'hosts', AggregationsStringTermsAggregate>>({
index,
body: {
size: 0,
aggs: {
hosts: {
terms: {
size: 5000,
field: 'host'
}
}
}
}
})
for (const b of extractBucketsFromAggregation<string>(res.aggregations.hosts.buckets)) {
setResult.add(b.key)
}
}
return Array.from(setResult)
}
function getRemovedHosts (dbHosts: string[], indexHosts: string[]) {
return dbHosts.filter(dbHost => indexHosts.includes(dbHost) === false)
}

View File

@ -1,234 +0,0 @@
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { elasticSearch } from '../../helpers/elastic-search'
import { logger } from '../../helpers/logger'
import { buildUrl } from '../../helpers/utils'
import { CONFIG, ELASTIC_SEARCH_QUERY } from '../../initializers/constants'
import { DBPlaylist, EnhancedPlaylist, IndexablePlaylist } from '../../types/playlist.model'
import { PlaylistsSearchQuery } from '../../types/search-query/playlist-search.model'
import { buildSort, extractSearchQueryResult } from './elastic-search-queries'
import { addUUIDFilters, buildMultiMatchBool } from './shared'
import { buildChannelOrAccountSummaryMapping, formatActorForDB, formatActorSummaryForAPI } from './shared/elastic-search-actor'
async function queryPlaylists (search: PlaylistsSearchQuery) {
const bool: any = {}
const mustNot: any[] = []
const filter: any[] = []
if (search.search) {
Object.assign(bool, buildMultiMatchBool(search.search, ELASTIC_SEARCH_QUERY.PLAYLISTS_MULTI_MATCH_FIELDS))
}
if (search.blockedAccounts) {
mustNot.push({
terms: {
'ownerAccount.handle': search.blockedAccounts
}
})
}
if (search.blockedHosts) {
mustNot.push({
terms: {
host: search.blockedHosts
}
})
}
mustNot.push({
term: {
videosLength: 0
}
})
if (search.host) {
filter.push({
term: {
'ownerAccount.host': search.host
}
})
}
if (search.uuids) {
addUUIDFilters(filter, search.uuids)
}
if (filter.length !== 0) {
Object.assign(bool, { filter })
}
if (mustNot.length !== 0) {
Object.assign(bool, { must_not: mustNot })
}
const body = {
from: search.start,
size: search.count,
sort: buildSort(search.sort),
query: { bool }
}
logger.debug({ body }, 'Will query Elastic Search for playlists.')
const res = await elasticSearch.search({
index: CONFIG.ELASTIC_SEARCH.INDEXES.PLAYLISTS,
body
})
return extractSearchQueryResult(res)
}
function formatPlaylistForAPI (p: DBPlaylist, fromHost?: string): EnhancedPlaylist {
return {
id: p.id,
uuid: p.uuid,
shortUUID: p.shortUUID,
score: p.score,
isLocal: fromHost === p.host,
url: p.url,
displayName: p.displayName,
description: p.description,
privacy: {
id: p.privacy.id,
label: p.privacy.label
},
videosLength: p.videosLength,
type: {
id: p.type.id,
label: p.type.label
},
thumbnailPath: p.thumbnailPath,
thumbnailUrl: buildUrl(p.host, p.thumbnailPath),
embedPath: p.embedPath,
embedUrl: buildUrl(p.host, p.embedPath),
createdAt: p.createdAt,
updatedAt: p.updatedAt,
ownerAccount: formatActorSummaryForAPI(p.ownerAccount),
videoChannel: formatActorSummaryForAPI(p.videoChannel)
}
}
function formatPlaylistForDB (p: IndexablePlaylist): DBPlaylist {
return {
id: p.id,
uuid: p.uuid,
shortUUID: p.shortUUID,
indexedAt: new Date(),
createdAt: p.createdAt,
updatedAt: p.updatedAt,
host: p.host,
url: p.url,
displayName: p.displayName,
description: p.description,
thumbnailPath: p.thumbnailPath,
embedPath: p.embedPath,
type: {
id: p.type.id,
label: p.type.label
},
privacy: {
id: p.privacy.id,
label: p.privacy.label
},
videosLength: p.videosLength,
ownerAccount: formatActorForDB(p.ownerAccount),
videoChannel: formatActorForDB(p.videoChannel)
}
}
function buildPlaylistsMapping () {
return {
id: {
type: 'long'
},
uuid: {
type: 'keyword'
},
shortUUID: {
type: 'keyword'
},
createdAt: {
type: 'date',
format: 'date_optional_time'
},
updatedAt: {
type: 'date',
format: 'date_optional_time'
},
indexedAt: {
type: 'date',
format: 'date_optional_time'
},
privacy: {
properties: {
id: {
type: 'keyword'
},
label: {
type: 'text'
}
}
},
displayName: {
type: 'text'
},
description: {
type: 'text'
},
thumbnailPath: {
type: 'keyword'
},
embedPath: {
type: 'keyword'
},
url: {
type: 'keyword'
},
host: {
type: 'keyword'
},
videosLength: {
type: 'long'
},
ownerAccount: {
properties: buildChannelOrAccountSummaryMapping()
},
videoChannel: {
properties: buildChannelOrAccountSummaryMapping()
}
} as Record<PropertyName, MappingProperty>
}
export {
formatPlaylistForAPI,
buildPlaylistsMapping,
formatPlaylistForDB,
queryPlaylists
}

View File

@ -1,134 +0,0 @@
import { difference } from 'lodash'
import { estypes } from '@elastic/elasticsearch'
import { AggregationsBuckets, AggregationsStringTermsAggregate, AggregationsStringTermsBucket } from '@elastic/elasticsearch/lib/api/types'
import { elasticSearch } from '../../helpers/elastic-search'
import { logger } from '../../helpers/logger'
async function removeNotExistingIdsFromHost (indexName: string, host: string, existingIds: Set<number>) {
const idsFromDB = await getIdsOf(indexName, host)
// Elastic Search limits max terms amount
const idsToRemove = difference(idsFromDB, Array.from(existingIds)).splice(0, 50000)
logger.info({ idsToRemove }, 'Will remove %d entries from %s of host %s.', idsToRemove.length, indexName, host)
return elasticSearch.deleteByQuery({
index: indexName,
body: {
query: {
bool: {
filter: [
{
terms: {
id: idsToRemove
}
},
{
term: {
host
}
}
]
}
}
}
})
}
function removeFromHosts (indexName: string, hosts: string[]) {
if (hosts.length === 0) return
logger.info({ hosts }, 'Will remove entries of index %s from hosts.', indexName)
return elasticSearch.deleteByQuery({
index: indexName,
body: {
query: {
bool: {
filter: {
terms: {
host: hosts
}
}
}
}
}
})
}
async function getIdsOf (indexName: string, host: string) {
const res = await elasticSearch.search<unknown, Record<'ids', AggregationsStringTermsAggregate>>({
index: indexName,
body: {
size: 0,
aggs: {
ids: {
terms: {
size: 500000,
field: 'id'
}
}
},
query: {
bool: {
filter: [
{
term: {
host
}
}
]
}
}
}
})
return extractBucketsFromAggregation<number>(res.aggregations.ids.buckets).map(b => b.key)
}
function extractSearchQueryResult (result: estypes.SearchResponse<any, any>) {
const hits = result.hits
return {
total: (hits.total as estypes.SearchTotalHits).value,
data: hits.hits.map(h => Object.assign(h._source, { score: h._score }))
}
}
function extractBucketsFromAggregation <T extends string | number> (buckets: AggregationsBuckets<AggregationsStringTermsBucket>) {
// FIXME: key returned by elastic search can also be a number
return buckets as unknown as { key: T }[]
}
function buildSort (value: string) {
let sortField: string
let direction: 'asc' | 'desc'
if (value.substring(0, 1) === '-') {
direction = 'desc'
sortField = value.substring(1)
} else {
direction = 'asc'
sortField = value
}
const field = sortField === 'match'
? '_score'
: sortField
return [
{
[field]: { order: direction }
}
]
}
export {
elasticSearch,
removeNotExistingIdsFromHost,
getIdsOf,
extractSearchQueryResult,
removeFromHosts,
buildSort,
extractBucketsFromAggregation
}

View File

@ -1,528 +0,0 @@
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { exists } from '../../helpers/custom-validators/misc'
import { elasticSearch } from '../../helpers/elastic-search'
import { logger } from '../../helpers/logger'
import { buildUrl } from '../../helpers/utils'
import { CONFIG, ELASTIC_SEARCH_QUERY } from '../../initializers/constants'
import { VideosSearchQuery } from '../../types/search-query/video-search.model'
import { DBVideo, DBVideoDetails, EnhancedVideo, IndexableVideo, IndexableVideoDetails } from '../../types/video.model'
import { buildSort, extractSearchQueryResult } from './elastic-search-queries'
import { addUUIDFilters, buildMultiMatchBool } from './shared'
import { buildChannelOrAccountSummaryMapping, formatActorForDB, formatActorSummaryForAPI } from './shared/elastic-search-actor'
async function queryVideos (search: VideosSearchQuery) {
const bool: any = {}
const filter: any[] = []
const mustNot: any[] = []
if (search.search) {
Object.assign(bool, buildMultiMatchBool(search.search, ELASTIC_SEARCH_QUERY.VIDEOS_MULTI_MATCH_FIELDS))
}
if (search.blockedAccounts) {
mustNot.push({
terms: {
'account.handle': search.blockedAccounts
}
})
}
if (search.blockedHosts) {
mustNot.push({
terms: {
host: search.blockedHosts
}
})
}
if (search.startDate) {
filter.push({
range: {
publishedAt: {
gte: search.startDate
}
}
})
}
if (search.endDate) {
filter.push({
range: {
publishedAt: {
lte: search.endDate
}
}
})
}
if (search.originallyPublishedStartDate) {
filter.push({
range: {
originallyPublishedAt: {
gte: search.originallyPublishedStartDate
}
}
})
}
if (search.originallyPublishedEndDate) {
filter.push({
range: {
originallyPublishedAt: {
lte: search.originallyPublishedEndDate
}
}
})
}
if (search.nsfw && search.nsfw !== 'both') {
filter.push({
term: {
nsfw: (search.nsfw + '') === 'true'
}
})
}
if (search.categoryOneOf) {
filter.push({
terms: {
'category.id': search.categoryOneOf
}
})
}
if (search.licenceOneOf) {
filter.push({
terms: {
'licence.id': search.licenceOneOf
}
})
}
if (search.languageOneOf) {
filter.push({
terms: {
'language.id': search.languageOneOf
}
})
}
if (search.tagsOneOf) {
filter.push({
terms: {
tags: search.tagsOneOf.map(t => t.toLowerCase())
}
})
}
if (search.tagsAllOf) {
for (const t of search.tagsAllOf) {
filter.push({
term: {
'tags.raw': {
value: t,
case_insensitive: true
}
}
})
}
}
if (search.durationMin) {
filter.push({
range: {
duration: {
gte: search.durationMin
}
}
})
}
if (search.durationMax) {
filter.push({
range: {
duration: {
lte: search.durationMax
}
}
})
}
if (exists(search.isLive)) {
filter.push({
term: {
isLive: search.isLive
}
})
}
if (search.host) {
filter.push({
term: {
'account.host': search.host
}
})
}
if (search.uuids) {
addUUIDFilters(filter, search.uuids)
}
Object.assign(bool, { filter })
if (mustNot.length !== 0) {
Object.assign(bool, { must_not: mustNot })
}
const body = {
from: search.start,
size: search.count,
sort: buildSort(search.sort)
}
// Allow to boost results depending on query languages
if (
CONFIG.VIDEOS_SEARCH.BOOST_LANGUAGES.ENABLED &&
Array.isArray(search.boostLanguages) &&
search.boostLanguages.length !== 0
) {
const boostScript = `
if (doc['language.id'].size() == 0) {
return _score;
}
String language = doc['language.id'].value;
for (String docLang: params.boostLanguages) {
if (docLang == language) return _score * params.boost;
}
return _score * params.malus;
`
Object.assign(body, {
query: {
script_score: {
query: { bool },
script: {
source: boostScript,
params: {
boostLanguages: search.boostLanguages,
boost: ELASTIC_SEARCH_QUERY.BOOST_LANGUAGE_VALUE,
malus: ELASTIC_SEARCH_QUERY.MALUS_LANGUAGE_VALUE
}
}
}
}
})
} else {
Object.assign(body, { query: { bool } })
}
logger.debug({ body }, 'Will query Elastic Search for videos.')
const res = await elasticSearch.search({
index: CONFIG.ELASTIC_SEARCH.INDEXES.VIDEOS,
body
})
return extractSearchQueryResult(res)
}
function buildVideosMapping () {
return {
id: {
type: 'long'
},
uuid: {
type: 'keyword'
},
shortUUID: {
type: 'keyword'
},
createdAt: {
type: 'date',
format: 'date_optional_time'
},
updatedAt: {
type: 'date',
format: 'date_optional_time'
},
publishedAt: {
type: 'date',
format: 'date_optional_time'
},
originallyPublishedAt: {
type: 'date',
format: 'date_optional_time'
},
indexedAt: {
type: 'date',
format: 'date_optional_time'
},
category: {
properties: {
id: {
type: 'keyword'
},
label: {
type: 'text'
}
}
},
licence: {
properties: {
id: {
type: 'keyword'
},
label: {
type: 'text'
}
}
},
language: {
properties: {
id: {
type: 'keyword'
},
label: {
type: 'text'
}
}
},
privacy: {
properties: {
id: {
type: 'keyword'
},
label: {
type: 'text'
}
}
},
name: {
type: 'text'
},
description: {
type: 'text'
},
tags: {
type: 'text',
fields: {
raw: {
type: 'keyword'
}
}
},
duration: {
type: 'long'
},
thumbnailPath: {
type: 'keyword'
},
previewPath: {
type: 'keyword'
},
embedPath: {
type: 'keyword'
},
url: {
type: 'keyword'
},
views: {
type: 'long'
},
likes: {
type: 'long'
},
dislikes: {
type: 'long'
},
nsfw: {
type: 'boolean'
},
isLive: {
type: 'boolean'
},
host: {
type: 'keyword'
},
account: {
properties: buildChannelOrAccountSummaryMapping()
},
channel: {
properties: buildChannelOrAccountSummaryMapping()
}
} as Record<PropertyName, MappingProperty>
}
function formatVideoForDB (v: IndexableVideo | IndexableVideoDetails): DBVideo | DBVideoDetails {
const video = {
id: v.id,
uuid: v.uuid,
shortUUID: v.shortUUID,
indexedAt: new Date(),
createdAt: v.createdAt,
updatedAt: v.updatedAt,
publishedAt: v.publishedAt,
originallyPublishedAt: v.originallyPublishedAt,
category: {
id: v.category.id,
label: v.category.label
},
licence: {
id: v.licence.id,
label: v.licence.label
},
language: {
id: v.language.id,
label: v.language.label
},
privacy: {
id: v.privacy.id,
label: v.privacy.label
},
name: v.name,
truncatedDescription: v.truncatedDescription,
description: v.description,
waitTranscoding: v.waitTranscoding,
duration: v.duration,
thumbnailPath: v.thumbnailPath,
previewPath: v.previewPath,
embedPath: v.embedPath,
views: v.views,
viewers: v.viewers,
likes: v.likes,
dislikes: v.dislikes,
isLive: v.isLive || false,
nsfw: v.nsfw,
host: v.host,
url: v.url,
files: v.files,
streamingPlaylists: v.streamingPlaylists,
tags: (v as IndexableVideoDetails).tags ? (v as IndexableVideoDetails).tags : undefined,
account: formatActorForDB(v.account),
channel: formatActorForDB(v.channel)
}
if (isVideoDetails(v)) {
return {
...video,
trackerUrls: v.trackerUrls,
descriptionPath: v.descriptionPath,
support: v.support,
commentsEnabled: v.commentsEnabled,
downloadEnabled: v.downloadEnabled
}
}
return video
}
function formatVideoForAPI (v: DBVideoDetails, fromHost?: string): EnhancedVideo {
return {
id: v.id,
uuid: v.uuid,
shortUUID: v.shortUUID,
score: v.score,
createdAt: new Date(v.createdAt),
updatedAt: new Date(v.updatedAt),
publishedAt: new Date(v.publishedAt),
originallyPublishedAt: v.originallyPublishedAt,
category: {
id: v.category.id,
label: v.category.label
},
licence: {
id: v.licence.id,
label: v.licence.label
},
language: {
id: v.language.id,
label: v.language.label
},
privacy: {
id: v.privacy.id,
label: v.privacy.label
},
name: v.name,
description: v.description,
truncatedDescription: v.truncatedDescription,
duration: v.duration,
tags: v.tags,
thumbnailPath: v.thumbnailPath,
thumbnailUrl: buildUrl(v.host, v.thumbnailPath),
previewPath: v.previewPath,
previewUrl: buildUrl(v.host, v.previewPath),
embedPath: v.embedPath,
embedUrl: buildUrl(v.host, v.embedPath),
url: v.url,
isLocal: fromHost && fromHost === v.host,
views: v.views,
viewers: v.viewers,
likes: v.likes,
dislikes: v.dislikes,
isLive: v.isLive,
nsfw: v.nsfw,
account: formatActorSummaryForAPI(v.account),
channel: formatActorSummaryForAPI(v.channel)
}
}
export {
queryVideos,
formatVideoForDB,
formatVideoForAPI,
buildVideosMapping
}
// ---------------------------------------------------------------------------
function isVideoDetails (video: IndexableVideo | IndexableVideoDetails): video is IndexableVideoDetails {
return (video as IndexableVideoDetails).commentsEnabled !== undefined
}

View File

@ -1,104 +0,0 @@
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { AccountSummary, VideoChannelSummary } from '@peertube/peertube-types'
import { AdditionalActorAttributes } from '../../../types/actor.model'
import { formatActorImageForDB } from './'
import { buildActorImageMapping, formatActorImageForAPI, formatActorImagesForAPI, formatActorImagesForDB } from './elastic-search-avatar'
function buildChannelOrAccountSummaryMapping () {
return {
id: {
type: 'long'
},
name: {
type: 'text',
fields: {
raw: {
type: 'keyword'
}
}
},
displayName: {
type: 'text'
},
url: {
type: 'keyword'
},
host: {
type: 'keyword'
},
handle: {
type: 'keyword'
},
avatar: {
properties: buildActorImageMapping()
},
// Introduced in 4.2
avatars: {
properties: buildActorImageMapping()
}
} as Record<PropertyName, MappingProperty>
}
function buildChannelOrAccountCommonMapping () {
return {
...buildChannelOrAccountSummaryMapping(),
followingCount: {
type: 'long'
},
followersCount: {
type: 'long'
},
createdAt: {
type: 'date',
format: 'date_optional_time'
},
updatedAt: {
type: 'date',
format: 'date_optional_time'
},
description: {
type: 'text'
}
} as Record<PropertyName, MappingProperty>
}
function formatActorSummaryForAPI (actor: (AccountSummary | VideoChannelSummary) & AdditionalActorAttributes) {
return {
id: actor.id,
name: actor.name,
displayName: actor.displayName,
url: actor.url,
host: actor.host,
avatar: formatActorImageForAPI(actor.avatar),
avatars: formatActorImagesForAPI(actor.avatars, actor.avatar)
}
}
function formatActorForDB (actor: AccountSummary | VideoChannelSummary) {
return {
id: actor.id,
name: actor.name,
displayName: actor.displayName,
url: actor.url,
host: actor.host,
handle: `${actor.name}@${actor.host}`,
avatar: formatActorImageForDB(actor.avatar, actor.host),
avatars: formatActorImagesForDB(actor.avatars, actor.host)
}
}
export {
buildChannelOrAccountCommonMapping,
buildChannelOrAccountSummaryMapping,
formatActorSummaryForAPI,
formatActorForDB
}

View File

@ -1,77 +0,0 @@
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { ActorImage } from '@peertube/peertube-types'
import { buildUrl } from '../../../helpers/utils'
function formatActorImageForAPI (image?: ActorImage) {
if (!image) return null
return {
url: image.url,
path: image.path,
width: image.width,
createdAt: image.createdAt,
updatedAt: image.updatedAt
}
}
function formatActorImagesForAPI (images?: ActorImage[], image?: ActorImage) {
// Does not exist in PeerTube < 4.2
if (!images) {
if (!image) return []
return [ formatActorImageForAPI(image) ]
}
return images.map(a => formatActorImageForAPI(a))
}
// ---------------------------------------------------------------------------
function formatActorImageForDB (image: ActorImage, host: string) {
if (!image) return null
return {
url: buildUrl(host, image.path),
path: image.path,
width: image.width,
createdAt: image.createdAt,
updatedAt: image.updatedAt
}
}
function formatActorImagesForDB (images: ActorImage[], host: string) {
if (!images) return null
return images.map(image => formatActorImageForDB(image, host))
}
// ---------------------------------------------------------------------------
function buildActorImageMapping () {
return {
path: {
type: 'keyword'
},
width: {
type: 'long'
},
createdAt: {
type: 'date',
format: 'date_optional_time'
},
updatedAt: {
type: 'date',
format: 'date_optional_time'
}
} as Record<PropertyName, MappingProperty>
}
export {
formatActorImageForAPI,
formatActorImagesForAPI,
formatActorImageForDB,
formatActorImagesForDB,
buildActorImageMapping
}

View File

@ -1,3 +0,0 @@
export * from './elastic-search-actor'
export * from './elastic-search-avatar'
export * from './query-helpers'

View File

@ -1,88 +0,0 @@
import validator from 'validator'
import { ELASTIC_SEARCH_QUERY } from '../../../initializers/constants'
function addUUIDFilters (filters: any[], uuids: string[]) {
if (!filters) return
const result = {
shortUUIDs: [] as string[],
uuids: [] as string[]
}
for (const uuid of uuids) {
if (validator.isUUID(uuid)) result.uuids.push(uuid)
else result.shortUUIDs.push(uuid)
}
filters.push({
bool: {
should: [
{
terms: {
uuid: result.uuids
}
},
{
terms: {
shortUUID: result.shortUUIDs
}
}
]
}
})
}
function buildMultiMatchBool (search: string, fieldsObject: { default: string[], phrase: string[] }) {
return {
must: [
{
bool: {
should: [
{
multi_match: {
query: search,
fields: fieldsObject.default,
fuzziness: ELASTIC_SEARCH_QUERY.FUZZINESS,
operator: ELASTIC_SEARCH_QUERY.OPERATOR,
minimum_should_match: ELASTIC_SEARCH_QUERY.MINIMUM_SHOULD_MATCH
}
},
{
multi_match: {
query: search,
fields: fieldsObject.default,
operator: ELASTIC_SEARCH_QUERY.OPERATOR,
minimum_should_match: ELASTIC_SEARCH_QUERY.MINIMUM_SHOULD_MATCH,
type: 'cross_fields'
}
},
{
multi_match: {
query: search,
fields: fieldsObject.phrase,
type: 'phrase'
}
}
]
}
}
],
should: [
// Better score for exact search
{
multi_match: {
query: search,
fields: [ ...fieldsObject.default, ...fieldsObject.phrase ],
operator: ELASTIC_SEARCH_QUERY.OPERATOR,
minimum_should_match: ELASTIC_SEARCH_QUERY.MINIMUM_SHOULD_MATCH
}
}
]
}
}
export {
addUUIDFilters,
buildMultiMatchBool
}

View File

@ -1,31 +1,38 @@
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/constants'
import { CONFIG, SORTABLE_COLUMNS } from '../../initializers/constants'
import { DBChannel, IndexableChannel } from '../../types/channel.model'
import { buildChannelsMapping, formatChannelForDB } from '../elastic-search/elastic-search-channels'
import { formatChannelForDB } from '../meilisearch/meilisearch-channels'
import { getChannel } from '../requests/peertube-instance'
import { AbstractIndexer } from './shared'
export class ChannelIndexer extends AbstractIndexer <IndexableChannel, DBChannel> {
protected readonly primaryKey = 'primaryKey'
protected readonly filterableAttributes = [ 'url', 'host', 'videosCount', 'ownerAccount.handle', 'handle' ]
protected readonly sortableAttributes = SORTABLE_COLUMNS.CHANNELS_SEARCH
// Keep the order, most important first
protected readonly searchableAttributes = [ 'name', 'displayName', 'ownerAccount.name', 'ownerAccount.displayName', 'description' ]
protected readonly rankingRules = [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
'followersCount:desc'
]
constructor () {
super(CONFIG.ELASTIC_SEARCH.INDEXES.CHANNELS, formatChannelForDB)
this.indexQueue.drain(async () => {
logger.info('Refresh channels index.')
await this.refreshIndex()
})
super(CONFIG.MEILISEARCH.INDEXES.CHANNELS, formatChannelForDB)
}
async indexSpecificElement (host: string, name: string) {
await this.waitForBulkIndexation()
const channel = await getChannel(host, name)
logger.info('Indexing specific channel %s@%s.', name, host)
return this.indexElements([ channel ], true)
}
buildMapping () {
return buildChannelsMapping()
this.addElementsToBulkIndex([ channel ])
}
}

View File

@ -1,20 +1,21 @@
import { CONFIG } from '../../initializers/constants'
import { CONFIG, SORTABLE_COLUMNS } from '../../initializers/constants'
import { DBPlaylist, IndexablePlaylist } from '../../types/playlist.model'
import { buildPlaylistsMapping, formatPlaylistForDB } from '../elastic-search/elastic-search-playlists'
import { formatPlaylistForDB } from '../meilisearch/meilisearch-playlists'
import { AbstractIndexer } from './shared'
export class PlaylistIndexer extends AbstractIndexer <IndexablePlaylist, DBPlaylist> {
protected readonly primaryKey = 'uuid'
protected readonly filterableAttributes = [ 'uuid', 'host', 'ownerAccount.handle', 'ownerAccount.host', 'videosLength' ]
protected readonly sortableAttributes = SORTABLE_COLUMNS.PLAYLISTS_SEARCH
// Keep the order, most important first
protected readonly searchableAttributes = [ 'displayName', 'videoChannel.displayName', 'ownerAccount.displayName', 'description' ]
constructor () {
super(CONFIG.ELASTIC_SEARCH.INDEXES.PLAYLISTS, formatPlaylistForDB)
super(CONFIG.MEILISEARCH.INDEXES.PLAYLISTS, formatPlaylistForDB)
}
async indexSpecificElement (host: string, uuid: string) {
async indexSpecificElement (host: string, uuid: string): Promise<any> {
// We don't need to index a specific element yet, since we have all playlist information in the list endpoint
throw new Error('Not implemented')
}
buildMapping () {
return buildPlaylistsMapping()
}
}

View File

@ -1,65 +1,137 @@
import { AsyncQueue, queue } from 'async'
import { QueueObject, queue } from 'async'
import { inspect } from 'util'
import { MappingProperty, PropertyName } from '@elastic/elasticsearch/lib/api/types'
import { logger } from '../../../helpers/logger'
import { INDEXER_QUEUE_CONCURRENCY } from '../../../initializers/constants'
import { buildIndex, indexDocuments, refreshIndex } from '../../../lib/elastic-search/elastic-search-index'
import { removeFromHosts, removeNotExistingIdsFromHost } from '../../../lib/elastic-search/elastic-search-queries'
import { CONFIG, INDEXER_QUEUE_CONCURRENCY } from '../../../initializers/constants'
import { IndexableDoc } from '../../../types/indexable-doc.model'
import { client } from '../../../helpers/meilisearch'
import { buildInValuesArray } from '../../meilisearch/meilisearch-queries'
import { EnqueuedTask } from 'meilisearch'
// identifier could be an uuid, an handle or a url for example
export type QueueParam = { host: string, identifier: string }
export abstract class AbstractIndexer <T extends IndexableDoc, DB> {
protected readonly indexQueue: AsyncQueue<QueueParam>
protected readonly indexQueue: QueueObject<QueueParam>
abstract indexSpecificElement (host: string, uuid: string): Promise<any>
abstract buildMapping (): Record<PropertyName, MappingProperty>
protected abstract readonly primaryKey: keyof DB
protected abstract readonly filterableAttributes: string[]
protected abstract readonly sortableAttributes: string[]
protected abstract readonly searchableAttributes: string[]
protected readonly rankingRules: string[]
private elementsToBulkIndex: T[] = []
private bulkIndexationTimer: any
private bulkProcessEnqueuedTask: EnqueuedTask
abstract indexSpecificElement (host: string, uuid: string): Promise<void>
constructor (
protected readonly indexName: string,
protected readonly formatterFn: (o: T) => DB
) {
this.indexQueue = queue<QueueParam, Error>((task, cb) => {
this.indexSpecificElement(task.host, task.identifier)
.then(() => cb())
.catch(err => {
logger.error(
{ err: inspect(err) },
'Error in index specific element %s of %s in index %s.', task.identifier, task.host, this.indexName
)
cb()
})
this.indexQueue = queue<QueueParam, Error>(async (task, cb) => {
try {
await this.indexSpecificElement(task.host, task.identifier)
return cb()
} catch (err) {
logger.error(
{ err: inspect(err) },
'Error in index specific element %s of %s in index %s.', task.identifier, task.host, this.indexName
)
cb()
}
}, INDEXER_QUEUE_CONCURRENCY)
}
initIndex () {
return buildIndex(this.indexName, this.buildMapping())
async initIndex () {
const { results } = await client.getIndexes()
if (!CONFIG.MEILISEARCH.FORCE_SETTINGS_UPDATE_AT_STARTUP && results.some(r => r.uid === this.indexName)) {
logger.info(this.indexName + ' already exists, skipping configuration')
return
}
logger.info(`Creating/updating "${this.indexName}" index settings`)
await client.index(this.indexName).updateSearchableAttributes(this.searchableAttributes)
await client.index(this.indexName).updateFilterableAttributes(this.filterableAttributes)
await client.index(this.indexName).updateSortableAttributes(this.sortableAttributes)
await client.index(this.indexName).updateFaceting({ maxValuesPerFacet: 10_000 })
await client.index(this.indexName).updatePagination({ maxTotalHits: 10_000 })
if (this.rankingRules) {
await client.index(this.indexName).updateRankingRules(this.rankingRules)
}
}
scheduleIndexation (host: string, identifier: string) {
// ---------------------------------------------------------------------------
removeNotExisting (host: string, existingPrimaryKeys: Set<string>) {
return client.index(this.indexName).deleteDocuments({
filter: `${this.primaryKey.toString()} NOT IN ${buildInValuesArray(Array.from(existingPrimaryKeys))} AND host = ${host}`
})
}
removeFromHosts (existingHosts: string[]) {
return client.index(this.indexName).deleteDocuments({
filter: 'host NOT IN ' + buildInValuesArray(Array.from(existingHosts))
})
}
// ---------------------------------------------------------------------------
scheduleParallelIndexation (host: string, identifier: string) {
this.indexQueue.push({ identifier, host })
.catch(err => logger.error({ err: inspect(err) }, 'Cannot schedule indexation of %s for %s', identifier, host))
}
refreshIndex () {
return refreshIndex(this.indexName)
async waitForBulkIndexation () {
if (!this.bulkProcessEnqueuedTask) return
await this.waitForTask(this.bulkProcessEnqueuedTask.taskUid, 1000)
this.bulkProcessEnqueuedTask = undefined
}
removeNotExisting (host: string, existingIds: Set<number>) {
return removeNotExistingIdsFromHost(this.indexName, host, existingIds)
addElementsToBulkIndex (elements: T[]) {
this.elementsToBulkIndex = this.elementsToBulkIndex.concat(elements)
this.scheduleBulkIndexationProcess()
}
removeFromHosts (hosts: string[]) {
return removeFromHosts(this.indexName, hosts)
private scheduleBulkIndexationProcess () {
if (this.bulkIndexationTimer) return
this.bulkIndexationTimer = setTimeout(async () => {
try {
const elements = this.elementsToBulkIndex
this.elementsToBulkIndex = []
logger.info(`Bulk indexing ${elements.length} elements in ${this.indexName}`)
this.bulkProcessEnqueuedTask = await this.indexElements(elements)
this.bulkIndexationTimer = undefined
} catch (err) {
logger.error({ err }, 'Cannot schedule bulk indexation')
}
}, CONFIG.INDEXER.BULK_INDEXATION_INTERVAL_MS)
}
indexElements (elements: T[], replace = false) {
return indexDocuments({
objects: elements,
formatter: v => this.formatterFn(v),
replace,
index: this.indexName
})
private async indexElements (elements: T[]) {
const documents = elements.map(e => this.formatterFn(e))
const result = await client.index(this.indexName).updateDocuments(documents, { primaryKey: this.primaryKey.toString() })
logger.debug(result, 'Indexed ' + documents.length + ' documents in ' + this.indexName)
return result
}
// ---------------------------------------------------------------------------
private waitForTask (taskId: number, intervalMs?: number) {
return client.index(this.indexName).waitForTask(taskId, { timeOutMs: 1000 * 60 * 5, intervalMs })
}
}

View File

@ -1,25 +1,63 @@
import { QueueObject } from 'async'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/constants'
import { DBVideo, IndexableVideo } from '../../types/video.model'
import { buildVideosMapping, formatVideoForDB } from '../elastic-search/elastic-search-videos'
import { AbstractIndexer, QueueParam } from './shared'
import { CONFIG, SORTABLE_COLUMNS } from '../../initializers/constants'
import { formatVideoForDB } from '../meilisearch/meilisearch-videos'
import { getVideo } from '../requests/peertube-instance'
import { AbstractIndexer } from './shared'
import { DBVideo, IndexableVideo } from '../../types/video.model'
export class VideoIndexer extends AbstractIndexer <IndexableVideo, DBVideo> {
protected readonly indexQueue: QueueObject<QueueParam>
protected readonly primaryKey = 'uuid'
protected readonly filterableAttributes = [
'uuid',
'host',
'account.handle',
'account.host',
'publishedAt',
'originallyPublishedAt',
'nsfw',
'category.id',
'licence.id',
'language.id',
'tags',
'duration',
'isLive'
]
protected readonly sortableAttributes = SORTABLE_COLUMNS.VIDEOS_SEARCH
// Keep the order, most important first
protected readonly searchableAttributes = [
'name',
'tags',
'account.displayName',
'channel.displayName',
'searchableDescription'
]
protected readonly rankingRules = [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
'language:asc',
'views:desc'
]
constructor () {
super(CONFIG.ELASTIC_SEARCH.INDEXES.VIDEOS, formatVideoForDB)
super(CONFIG.MEILISEARCH.INDEXES.VIDEOS, formatVideoForDB)
}
async indexSpecificElement (host: string, uuid: string) {
await this.waitForBulkIndexation()
const video = await getVideo(host, uuid)
logger.info('Indexing specific video %s of %s.', uuid, host)
return this.indexElements([ video ], true)
}
buildMapping () {
return buildVideosMapping()
this.addElementsToBulkIndex([ video ])
}
}

View File

@ -0,0 +1,150 @@
import { createHash } from 'crypto'
import { logger } from '../../helpers/logger'
import { client } from '../../helpers/meilisearch'
import { CONFIG } from '../../initializers/constants'
import { DBChannel, APIVideoChannel, IndexableChannel } from '../../types/channel.model'
import { ChannelsSearchQuery } from '../../types/search-query/channel-search.model'
import { buildInValuesArray, buildSort, extractSearchQueryResult } from './meilisearch-queries'
import {
formatActorImageForAPI,
formatActorImageForDB,
formatActorImagesForAPI,
formatActorImagesForDB
} from './shared/meilisearch-avatar'
export async function queryChannels (search: ChannelsSearchQuery) {
const filter: string[] = [ 'videosCount != 0' ]
if (search.host) filter.push(`host = '${search.host}'`)
if (search.blockedAccounts && search.blockedAccounts.length !== 0) {
filter.push(`ownerAccount.handle NOT IN ${buildInValuesArray(search.blockedAccounts)}`)
}
if (search.handles && search.handles.length !== 0) {
filter.push(`handle IN ${buildInValuesArray(search.handles)}`)
}
if (search.blockedHosts && search.blockedHosts.length !== 0) {
filter.push(`host NOT IN ${buildInValuesArray(search.blockedHosts)}`)
}
logger.debug({ filter }, 'Will query Meilisearch for channels.')
const result = await client.index(CONFIG.MEILISEARCH.INDEXES.CHANNELS).search(search.search, {
offset: search.start,
limit: search.count,
sort: buildSort(search.sort),
showRankingScore: true,
filter
})
return extractSearchQueryResult(result)
}
export function formatChannelForAPI (c: DBChannel, fromHost?: string): APIVideoChannel {
return {
id: c.id,
score: c._rankingScore,
url: c.url,
name: c.name,
host: c.host,
followingCount: c.followingCount,
followersCount: c.followersCount,
createdAt: new Date(c.createdAt),
updatedAt: new Date(c.updatedAt),
avatar: formatActorImageForAPI(c.avatar),
avatars: formatActorImagesForAPI(c.avatars, c.avatar),
banner: formatActorImageForAPI(c.banner),
banners: formatActorImagesForAPI(c.banners, c.banner),
displayName: c.displayName,
description: c.description,
support: c.support,
isLocal: fromHost === c.host,
videosCount: c.videosCount || 0,
ownerAccount: {
id: c.ownerAccount.id,
url: c.ownerAccount.url,
displayName: c.ownerAccount.displayName,
description: c.ownerAccount.description,
name: c.ownerAccount.name,
host: c.ownerAccount.host,
followingCount: c.ownerAccount.followingCount,
followersCount: c.ownerAccount.followersCount,
createdAt: new Date(c.ownerAccount.createdAt),
updatedAt: new Date(c.ownerAccount.updatedAt),
avatar: formatActorImageForAPI(c.ownerAccount.avatar),
avatars: formatActorImagesForAPI(c.ownerAccount.avatars, c.ownerAccount.avatar)
}
}
}
export function formatChannelForDB (c: IndexableChannel): DBChannel {
return {
primaryKey: buildDBChannelPrimaryKey(c),
id: c.id,
name: c.name,
host: c.host,
url: c.url,
avatar: formatActorImageForDB(c.avatar, c.host),
avatars: formatActorImagesForDB(c.avatars, c.host),
banner: formatActorImageForDB(c.banner, c.host),
banners: formatActorImagesForDB(c.banners, c.host),
displayName: c.displayName,
indexedAt: new Date().getTime(),
createdAt: new Date(c.createdAt).getTime(),
updatedAt: new Date(c.updatedAt).getTime(),
followingCount: c.followingCount,
followersCount: c.followersCount,
description: c.description,
support: c.support,
videosCount: c.videosCount,
handle: `${c.name}@${c.host}`,
ownerAccount: {
id: c.ownerAccount.id,
url: c.ownerAccount.url,
displayName: c.ownerAccount.displayName,
description: c.ownerAccount.description,
name: c.ownerAccount.name,
host: c.ownerAccount.host,
followingCount: c.ownerAccount.followingCount,
followersCount: c.ownerAccount.followersCount,
createdAt: new Date(c.ownerAccount.createdAt).getTime(),
updatedAt: new Date(c.ownerAccount.updatedAt).getTime(),
handle: `${c.ownerAccount.name}@${c.ownerAccount.host}`,
avatar: formatActorImageForDB(c.ownerAccount.avatar, c.ownerAccount.host),
avatars: formatActorImagesForDB(c.ownerAccount.avatars, c.ownerAccount.host)
}
}
}
export function buildDBChannelPrimaryKey (c: { id: number, host: string }) {
return `${c.id}_${md5(c.host)}`
}
// Collisions are fine, we just want to generate a primary key in an efficient way
function md5 (value: string) {
return createHash('md5').update(value).digest('hex')
}

View File

@ -0,0 +1,30 @@
import { CONFIG } from '../../initializers/constants'
import { listIndexInstancesHost, getMajorInstanceVersion } from '../requests/instances-index'
import { logger } from '../../helpers/logger'
import Bluebird from 'bluebird'
export async function buildInstanceHosts () {
let indexHosts = await listIndexInstancesHost()
if (CONFIG.INSTANCES_INDEX.WHITELIST.ENABLED) {
const whitelistHosts = Array.isArray(CONFIG.INSTANCES_INDEX.WHITELIST.HOSTS)
? CONFIG.INSTANCES_INDEX.WHITELIST.HOSTS
: []
indexHosts = indexHosts.filter(h => whitelistHosts.includes(h))
}
indexHosts = await Bluebird.filter(indexHosts, async indexHost => {
const instanceVersion = await getMajorInstanceVersion(indexHost)
if (instanceVersion < 4) {
logger.info(`Do not index ${indexHost} because the major version is too low (v${instanceVersion} < v4)`)
return false
}
return true
}, { concurrency: 10 })
return indexHosts
}

View File

@ -0,0 +1,115 @@
import { logger } from '../../helpers/logger'
import { client } from '../../helpers/meilisearch'
import { buildUrl } from '../../helpers/utils'
import { CONFIG } from '../../initializers/constants'
import { DBPlaylist, APIPlaylist, IndexablePlaylist } from '../../types/playlist.model'
import { PlaylistsSearchQuery } from '../../types/search-query/playlist-search.model'
import { buildInValuesArray, buildSort, extractSearchQueryResult } from './meilisearch-queries'
import { addUUIDFilters } from './shared'
import { formatActorForDB, formatActorSummaryForAPI } from './shared/meilisearch-actor'
export async function queryPlaylists (search: PlaylistsSearchQuery) {
const filter: string[] = [
'videosLength != 0'
]
if (search.host) filter.push(`ownerAccount.host = '${search.host}'`)
if (search.blockedAccounts && search.blockedAccounts.length !== 0) {
filter.push(`ownerAccount.handle NOT IN ${buildInValuesArray(search.blockedAccounts)}`)
}
if (search.blockedHosts && search.blockedHosts.length !== 0) {
filter.push(`host NOT IN ${buildInValuesArray(search.blockedHosts)}`)
}
if (search.uuids) addUUIDFilters(filter, search.uuids)
logger.debug({ filter }, 'Will query Meilisearch for playlists.')
const result = await client.index(CONFIG.MEILISEARCH.INDEXES.PLAYLISTS).search(search.search, {
offset: search.start,
limit: search.count,
sort: buildSort(search.sort),
showRankingScore: true,
filter
})
return extractSearchQueryResult(result)
}
export function formatPlaylistForAPI (p: DBPlaylist, fromHost?: string): APIPlaylist {
return {
id: p.id,
uuid: p.uuid,
shortUUID: p.shortUUID,
score: p._rankingScore,
isLocal: fromHost === p.host,
url: p.url,
displayName: p.displayName,
description: p.description,
privacy: {
id: p.privacy.id,
label: p.privacy.label
},
videosLength: p.videosLength,
type: {
id: p.type.id,
label: p.type.label
},
thumbnailPath: p.thumbnailPath,
thumbnailUrl: buildUrl(p.host, p.thumbnailPath),
embedPath: p.embedPath,
embedUrl: buildUrl(p.host, p.embedPath),
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt),
ownerAccount: formatActorSummaryForAPI(p.ownerAccount),
videoChannel: formatActorSummaryForAPI(p.videoChannel)
}
}
export function formatPlaylistForDB (p: IndexablePlaylist): DBPlaylist {
return {
id: p.id,
uuid: p.uuid,
shortUUID: p.shortUUID,
indexedAt: new Date().getTime(),
createdAt: new Date(p.createdAt).getTime(),
updatedAt: new Date(p.updatedAt).getTime(),
host: p.host,
url: p.url,
displayName: p.displayName,
description: p.description,
thumbnailPath: p.thumbnailPath,
embedPath: p.embedPath,
type: {
id: p.type.id,
label: p.type.label
},
privacy: {
id: p.privacy.id,
label: p.privacy.label
},
videosLength: p.videosLength,
ownerAccount: formatActorForDB(p.ownerAccount),
videoChannel: formatActorForDB(p.videoChannel)
}
}

View File

@ -0,0 +1,27 @@
import { SearchResponse } from 'meilisearch'
export function extractSearchQueryResult (result: SearchResponse<any, any>) {
return {
total: result.estimatedTotalHits,
data: result.hits
}
}
export function buildSort (value: string) {
if (value === '-match') return [ '_rankingScore:desc' ]
if (value === 'match') return [ '_rankingScore:asc' ]
if (value.substring(0, 1) === '-') {
return [ `${value.substring(1)}:desc`, '_rankingScore:desc' ]
}
return [ `${value}:asc`, '_rankingScore:desc' ]
}
export function buildInQuery (term: string, values: string[] | number[]) {
return `${term} IN ${buildInValuesArray(values)}`
}
export function buildInValuesArray (values: string[] | number[]) {
return '[' + values.map(v => `'${v.replace(/'/g, '\\\'')}'`).join(',') + ']'
}

View File

@ -0,0 +1,241 @@
import { exists } from '../../helpers/custom-validators/misc'
import { logger } from '../../helpers/logger'
import { client } from '../../helpers/meilisearch'
import { buildUrl } from '../../helpers/utils'
import { CONFIG } from '../../initializers/constants'
import { VideosSearchQuery } from '../../types/search-query/video-search.model'
import { DBVideo, DBVideoDetails, APIVideo, IndexableVideo, IndexableVideoDetails } from '../../types/video.model'
import { buildInQuery, buildInValuesArray, buildSort, extractSearchQueryResult } from './meilisearch-queries'
import { addUUIDFilters } from './shared'
import { formatActorForDB, formatActorSummaryForAPI } from './shared/meilisearch-actor'
export async function queryVideos (search: VideosSearchQuery) {
const filter: string[] = []
if (search.blockedAccounts && search.blockedAccounts.length !== 0) {
filter.push(`account.handle NOT IN ${buildInValuesArray(search.blockedAccounts)}`)
}
if (search.blockedHosts && search.blockedHosts.length !== 0) {
filter.push(`host NOT IN ${buildInValuesArray(search.blockedHosts)}`)
}
if (search.startDate) filter.push(`publishedAt >= ${new Date(search.startDate).getTime()}`)
if (search.endDate) filter.push(`publishedAt <= ${new Date(search.endDate).getTime()}`)
if (search.originallyPublishedStartDate) {
filter.push(`originallyPublishedAt >= ${new Date(search.originallyPublishedStartDate).getTime()}`)
}
if (search.originallyPublishedEndDate) {
filter.push(`originallyPublishedAt <= ${new Date(search.originallyPublishedEndDate).getTime()}`)
}
if (search.categoryOneOf && search.categoryOneOf.length !== 0) {
filter.push(`category.id IN ${buildInValuesArray(search.categoryOneOf)}`)
}
if (search.licenceOneOf && search.licenceOneOf.length !== 0) {
filter.push(`licence.id IN ${buildInValuesArray(search.licenceOneOf)}`)
}
if (search.languageOneOf && search.languageOneOf.length !== 0) {
filter.push(`language.id IN ${buildInValuesArray(search.languageOneOf)}`)
}
if (search.durationMin) filter.push(`duration >= ${search.durationMin}`)
if (search.durationMax) filter.push(`duration <= ${search.durationMax}`)
if (search.nsfw && search.nsfw !== 'both') filter.push(`nsfw = ${search.nsfw}`)
if (exists(search.isLive)) filter.push(`isLive = ${search.isLive}`)
if (search.host) filter.push(`account.host = '${search.host}'`)
if (search.uuids) addUUIDFilters(filter, search.uuids)
if (search.tagsOneOf && search.tagsOneOf.length !== 0) {
const tagsOneOf = search.tagsOneOf.map(t => t.toLowerCase())
filter.push(`tags IN ${buildInValuesArray(tagsOneOf)}`)
}
if (search.tagsAllOf && search.tagsAllOf.length !== 0) {
const tagsAllOf = search.tagsAllOf.map(t => t.toLowerCase())
const clause = tagsAllOf.map(tag => `tags = '${tag}'`).join(' AND ')
filter.push(clause)
}
logger.debug({ filter }, 'Will query Meilisearch for videos.')
if (search.boostLanguages && search.boostLanguages.length !== 0) {
// No linguistic content
filter.push(`language.id IN ${buildInValuesArray([ ...search.boostLanguages, 'zxx' ])} OR language.id IS NULL`)
}
const result = await client.index(CONFIG.MEILISEARCH.INDEXES.VIDEOS).search(search.search, {
offset: search.start,
limit: search.count,
sort: buildSort(search.sort),
showRankingScore: true,
filter
})
return extractSearchQueryResult(result)
}
export async function getVideosUpdatedAt (uuids: string[]): Promise<{ updatedAt: number, uuid: string }[]> {
const result = await client.index(CONFIG.MEILISEARCH.INDEXES.VIDEOS).getDocuments({
fields: [ 'updatedAt', 'uuid' ],
filter: [ buildInQuery('uuid', uuids) ]
})
return result.results
}
export function formatVideoForDB (v: IndexableVideo | IndexableVideoDetails): DBVideo | DBVideoDetails {
const video = {
id: v.id,
uuid: v.uuid,
shortUUID: v.shortUUID,
indexedAt: new Date().getTime(),
createdAt: new Date(v.createdAt).getTime(),
updatedAt: new Date(v.updatedAt).getTime(),
publishedAt: new Date(v.publishedAt).getTime(),
originallyPublishedAt: new Date(v.originallyPublishedAt).getTime(),
category: {
id: v.category.id,
label: v.category.label
},
licence: {
id: v.licence.id,
label: v.licence.label
},
language: {
id: v.language.id,
label: v.language.label
},
privacy: {
id: v.privacy.id,
label: v.privacy.label
},
name: v.name,
truncatedDescription: v.truncatedDescription,
description: v.description,
searchableDescription: (v.description || v.truncatedDescription || '').slice(0, 250),
waitTranscoding: v.waitTranscoding,
duration: v.duration,
thumbnailPath: v.thumbnailPath,
previewPath: v.previewPath,
embedPath: v.embedPath,
views: v.views,
viewers: v.viewers,
likes: v.likes,
dislikes: v.dislikes,
isLive: v.isLive || false,
nsfw: v.nsfw,
host: v.host,
url: v.url,
files: v.files,
streamingPlaylists: v.streamingPlaylists,
tags: (v as IndexableVideoDetails).tags ? (v as IndexableVideoDetails).tags : undefined,
account: formatActorForDB(v.account),
channel: formatActorForDB(v.channel)
}
if (isVideoDetails(v)) {
return {
...video,
trackerUrls: v.trackerUrls,
descriptionPath: v.descriptionPath,
support: v.support,
commentsEnabled: v.commentsEnabled,
downloadEnabled: v.downloadEnabled
}
}
return video
}
export function formatVideoForAPI (v: DBVideoDetails, fromHost?: string): APIVideo {
return {
id: v.id,
uuid: v.uuid,
shortUUID: v.shortUUID,
score: v._rankingScore,
createdAt: new Date(v.createdAt),
updatedAt: new Date(v.updatedAt),
publishedAt: new Date(v.publishedAt),
originallyPublishedAt: new Date(v.originallyPublishedAt),
category: {
id: v.category.id,
label: v.category.label
},
licence: {
id: v.licence.id,
label: v.licence.label
},
language: {
id: v.language.id,
label: v.language.label
},
privacy: {
id: v.privacy.id,
label: v.privacy.label
},
name: v.name,
description: v.description,
truncatedDescription: v.truncatedDescription,
duration: v.duration,
tags: v.tags,
thumbnailPath: v.thumbnailPath,
thumbnailUrl: buildUrl(v.host, v.thumbnailPath),
previewPath: v.previewPath,
previewUrl: buildUrl(v.host, v.previewPath),
embedPath: v.embedPath,
embedUrl: buildUrl(v.host, v.embedPath),
url: v.url,
isLocal: fromHost && fromHost === v.host,
views: v.views,
viewers: v.viewers,
likes: v.likes,
dislikes: v.dislikes,
isLive: v.isLive,
nsfw: v.nsfw,
account: formatActorSummaryForAPI(v.account),
channel: formatActorSummaryForAPI(v.channel)
}
}
// ---------------------------------------------------------------------------
export function isVideoDetails (video: IndexableVideo | IndexableVideoDetails): video is IndexableVideoDetails {
return (video as IndexableVideoDetails).commentsEnabled !== undefined
}

View File

@ -0,0 +1,3 @@
export * from './meilisearch-actor'
export * from './meilisearch-avatar'
export * from './query-helpers'

View File

@ -0,0 +1,32 @@
import { AccountSummary, VideoChannelSummary } from '@peertube/peertube-types'
import { formatActorImageForAPI, formatActorImageForDB, formatActorImagesForAPI, formatActorImagesForDB } from './meilisearch-avatar'
import { DBAccountSummary } from '../../../types/account.model'
import { DBChannelSummary } from '../../../types/channel.model'
export function formatActorSummaryForAPI (actor: DBAccountSummary | DBChannelSummary) {
return {
id: actor.id,
name: actor.name,
displayName: actor.displayName,
url: actor.url,
host: actor.host,
avatar: formatActorImageForAPI(actor.avatar),
avatars: formatActorImagesForAPI(actor.avatars, actor.avatar)
}
}
export function formatActorForDB (actor: AccountSummary | VideoChannelSummary) {
return {
id: actor.id,
name: actor.name,
displayName: actor.displayName,
url: actor.url,
host: actor.host,
handle: `${actor.name}@${actor.host}`,
avatar: formatActorImageForDB(actor.avatar, actor.host),
avatars: formatActorImagesForDB(actor.avatars, actor.host)
}
}

View File

@ -0,0 +1,46 @@
import { ActorImage } from '@peertube/peertube-types'
import { buildUrl } from '../../../helpers/utils'
import { DBActorImage } from '../../../types/actor.model'
export function formatActorImageForAPI (image?: DBActorImage) {
if (!image) return null
return {
url: image.url,
path: image.path,
width: image.width,
createdAt: new Date(image.createdAt),
updatedAt: new Date(image.updatedAt)
}
}
export function formatActorImagesForAPI (images?: DBActorImage[], image?: DBActorImage) {
// Does not exist in PeerTube < 4.2
if (!images) {
if (!image) return []
return [ formatActorImageForAPI(image) ]
}
return images.map(a => formatActorImageForAPI(a))
}
// ---------------------------------------------------------------------------
export function formatActorImageForDB (image: ActorImage, host: string) {
if (!image) return null
return {
url: buildUrl(host, image.path),
path: image.path,
width: image.width,
createdAt: new Date(image.createdAt).getTime(),
updatedAt: new Date(image.updatedAt).getTime()
}
}
export function formatActorImagesForDB (images: ActorImage[], host: string) {
if (!images) return null
return images.map(image => formatActorImageForDB(image, host))
}

View File

@ -0,0 +1,22 @@
import validator from 'validator'
import { buildInQuery } from '../meilisearch-queries'
export function addUUIDFilters (filters: string[], uuids: string[]) {
if (!filters || filters.length === 0) return
const result = {
shortUUIDs: [] as string[],
uuids: [] as string[]
}
for (const uuid of uuids) {
if (validator.isUUID(uuid)) result.uuids.push(uuid)
else result.shortUUIDs.push(uuid)
}
const parts: string[] = []
if (result.uuids.length !== 0) parts.push(buildInQuery('uuid', result.uuids))
if (result.shortUUIDs.length !== 0) parts.push(buildInQuery('shortUUID', result.shortUUIDs))
filters.push(parts.join(' OR '))
}

View File

@ -1,10 +1,10 @@
import { IndexablePlaylist } from 'server/types/playlist.model'
import { ResultList, Video, VideoChannel, VideoDetails, VideoPlaylist, VideosCommonQuery } from '@peertube/peertube-types'
import { doRequestWithRetries } from '../../helpers/requests'
import { INDEXER_COUNT, REQUESTS } from '../../initializers/constants'
import { IndexableChannel } from '../../types/channel.model'
import { IndexableDoc } from '../../types/indexable-doc.model'
import { IndexableVideo } from '../../types/video.model'
import { IndexablePlaylist } from '../../types/playlist.model'
async function getVideo (host: string, uuid: string): Promise<IndexableVideo> {
const url = 'https://' + host + '/api/v1/videos/' + uuid
@ -91,7 +91,6 @@ async function getPlaylistsOf (host: string, handle: string, start: number): Pro
function prepareVideoForDB <T extends Video> (video: T, host: string): T & IndexableDoc {
return Object.assign(video, {
elasticSearchId: host + video.id,
host,
url: 'https://' + host + '/videos/watch/' + video.uuid
})
@ -99,7 +98,6 @@ function prepareVideoForDB <T extends Video> (video: T, host: string): T & Index
function prepareChannelForDB (channel: VideoChannel, host: string, videosCount: number): IndexableChannel {
return Object.assign(channel, {
elasticSearchId: host + channel.id,
host,
videosCount,
url: 'https://' + host + '/video-channels/' + channel.name
@ -108,7 +106,6 @@ function prepareChannelForDB (channel: VideoChannel, host: string, videosCount:
function preparePlaylistForDB (playlist: VideoPlaylist, host: string): IndexablePlaylist {
return Object.assign(playlist, {
elasticSearchId: host + playlist.id,
host,
url: 'https://' + host + '/videos/watch/playlist/' + playlist.uuid
})

View File

@ -1,15 +1,17 @@
import Bluebird from 'bluebird'
import { IndexablePlaylist } from 'server/types/playlist.model'
import { inspect } from 'util'
import { logger } from '../../helpers/logger'
import { INDEXER_HOST_CONCURRENCY, INDEXER_COUNT, INDEXER_LIMIT, SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
import { INDEXER_COUNT, INDEXER_LIMIT, SCHEDULER_INTERVALS_MS, CONFIG } from '../../initializers/constants'
import { IndexableVideo } from '../../types/video.model'
import { buildInstanceHosts } from '../elastic-search/elastic-search-instances'
import { buildInstanceHosts } from '../meilisearch/meilisearch-instances'
import { ChannelIndexer } from '../indexers/channel-indexer'
import { PlaylistIndexer } from '../indexers/playlist-indexer'
import { VideoIndexer } from '../indexers/video-indexer'
import { getPlaylistsOf, getVideos } from '../requests/peertube-instance'
import { AbstractScheduler } from './abstract-scheduler'
import { IndexablePlaylist } from '../../types/playlist.model'
import { buildDBChannelPrimaryKey } from '../meilisearch/meilisearch-channels'
import { getVideosUpdatedAt } from '../meilisearch/meilisearch-videos'
export class IndexationScheduler extends AbstractScheduler {
@ -54,35 +56,30 @@ export class IndexationScheduler extends AbstractScheduler {
private async runIndexer () {
logger.info('Running indexer.')
const { indexHosts, removedHosts } = await buildInstanceHosts()
this.indexedHosts = indexHosts
this.indexedHosts = await buildInstanceHosts()
logger.info({ indexHosts: this.indexedHosts }, `Will index ${this.indexedHosts.length} hosts and remove non existing hosts`)
for (const o of this.indexers) {
await o.removeFromHosts(removedHosts)
await o.removeFromHosts(this.indexedHosts)
}
logger.info({ indexHosts }, 'Will index %s hosts', indexHosts.length)
await Bluebird.map(indexHosts, async host => {
await Bluebird.map(this.indexedHosts, async host => {
try {
await this.indexHost(host)
} catch (err) {
console.error(inspect(err, { depth: 10 }))
logger.warn({ err: inspect(err) }, 'Cannot index videos from %s.', host)
}
}, { concurrency: INDEXER_HOST_CONCURRENCY })
for (const o of this.indexers) {
await o.refreshIndex()
}
}, { concurrency: CONFIG.INDEXER.HOST_CONCURRENCY })
logger.info('Indexer ended.')
}
private async indexHost (host: string) {
const channelsToSync = new Set<string>()
const existingChannelsId = new Set<number>()
const existingVideosId = new Set<number>()
const existingChannelsId = new Set<string>()
const existingVideosId = new Set<string>()
let videos: IndexableVideo[] = []
let start = 0
@ -90,6 +87,8 @@ export class IndexationScheduler extends AbstractScheduler {
logger.info('Adding video data from %s.', host)
do {
await this.videoIndexer.waitForBulkIndexation()
logger.debug('Getting video results from %s (from = %d).', host, start)
videos = await getVideos(host, start)
@ -98,30 +97,36 @@ export class IndexationScheduler extends AbstractScheduler {
start += videos.length
if (videos.length !== 0) {
const { created } = await this.videoIndexer.indexElements(videos)
const videosFromDB = await getVideosUpdatedAt(videos.map(v => v.uuid))
logger.debug('Indexed %d videos from %s.', videos.length, host)
logger.debug('Indexing %d videos from %s.', videos.length, host)
this.videoIndexer.addElementsToBulkIndex(videos)
// Fetch complete video foreach created video (to get tags)
for (const c of created) {
this.videoIndexer.scheduleIndexation(host, c.uuid)
// Fetch complete video foreach created video (to get tags) if needed
for (const video of videos) {
const videoDB = videosFromDB.find(v => v.uuid === video.uuid)
if (!videoDB || videoDB.updatedAt !== new Date(video.updatedAt).getTime()) {
this.videoIndexer.scheduleParallelIndexation(host, video.uuid)
}
}
}
for (const video of videos) {
channelsToSync.add(video.channel.name)
existingChannelsId.add(video.channel.id)
existingVideosId.add(video.id)
existingChannelsId.add(buildDBChannelPrimaryKey(video.channel))
existingVideosId.add(video.uuid)
}
} while (videos.length === INDEXER_COUNT && start < INDEXER_LIMIT)
logger.info('Added video data from %s.', host)
for (const c of channelsToSync) {
this.channelIndexer.scheduleIndexation(host, c)
this.channelIndexer.scheduleParallelIndexation(host, c)
}
logger.info('Removing non-existing channels and videos from ' + host)
await this.channelIndexer.removeNotExisting(host, existingChannelsId)
await this.videoIndexer.removeNotExisting(host, existingVideosId)
@ -129,7 +134,7 @@ export class IndexationScheduler extends AbstractScheduler {
}
private async indexPlaylists (host: string, channelHandles: string[]) {
const existingPlaylistsId = new Set<number>()
const existingPlaylistsId = new Set<string>()
logger.info('Adding playlist data from %s.', host)
@ -138,6 +143,8 @@ export class IndexationScheduler extends AbstractScheduler {
let start = 0
do {
await this.playlistIndexer.waitForBulkIndexation()
logger.debug('Getting playlist results from %s (from = %d, channelHandle = %s).', host, start, channelHandle)
playlists = await getPlaylistsOf(host, channelHandle, start)
@ -146,13 +153,13 @@ export class IndexationScheduler extends AbstractScheduler {
start += playlists.length
if (playlists.length !== 0) {
await this.playlistIndexer.indexElements(playlists)
this.playlistIndexer.addElementsToBulkIndex(playlists)
logger.debug('Indexed %d playlists from %s.', playlists.length, host)
}
for (const playlist of playlists) {
existingPlaylistsId.add(playlist.id)
existingPlaylistsId.add(playlist.uuid)
}
} while (playlists.length === INDEXER_COUNT && start < INDEXER_LIMIT)
}

View File

@ -0,0 +1,25 @@
import { Account, AccountSummary } from '@peertube/peertube-types'
import { DBActorImage } from './actor.model'
type IgnoredAccountFields = 'createdAt' | 'updatedAt' | 'avatar' | 'avatars'
interface DBAccountShared {
handle: string
avatar: DBActorImage
avatars: DBActorImage[]
}
export interface DBAccount extends Omit<Account, IgnoredAccountFields> {
handle: string
avatar: DBActorImage
avatars: DBActorImage[]
createdAt: number
updatedAt: number
}
export interface DBAccountSummary extends DBAccountShared, Omit<AccountSummary, 'avatar' | 'avatars'> {
}

View File

@ -1,11 +1,7 @@
import { ActorImage } from '@peertube/peertube-types'
export type AdditionalActorAttributes = {
handle: string
export type DBActorImage = Omit<ActorImage, 'createdAt' | 'updatedAt'> & {
url: string
avatar: ActorImageExtended
avatars: ActorImageExtended[]
createdAt: number
updatedAt: number
}
export type ActorImageExtended = ActorImage & { url: string }

View File

@ -1,33 +1,42 @@
import { Account, VideoChannel, VideoChannelSummary } from '@peertube/peertube-types'
import { ActorImageExtended, AdditionalActorAttributes } from './actor.model'
import { VideoChannel, VideoChannelSummary } from '@peertube/peertube-types'
import { DBActorImage } from './actor.model'
import { IndexableDoc } from './indexable-doc.model'
import { DBAccount } from './account.model'
export interface IndexableChannel extends VideoChannel, IndexableDoc {
url: string
}
export interface DBChannel extends Omit<VideoChannel, 'isLocal'> {
indexedAt: Date
interface DBChannelShared {
handle: string
url: string
ownerAccount?: Account & AdditionalActorAttributes
avatar: ActorImageExtended
avatars: ActorImageExtended[]
banner: ActorImageExtended
banners: ActorImageExtended[]
score?: number
avatar: DBActorImage
avatars: DBActorImage[]
}
export interface DBChannelSummary extends VideoChannelSummary {
indexedAt: Date
type IgnoredChannelFields = 'ownerAccount' | 'isLocal' | 'createdAt' | 'updatedAt' | 'avatar' | 'avatars' | 'banner' | 'banners'
export interface DBChannel extends DBChannelShared, Omit<VideoChannel, IgnoredChannelFields> {
indexedAt: number
primaryKey: string
ownerAccount?: DBAccount
banner: DBActorImage
banners: DBActorImage[]
createdAt: number
updatedAt: number
_rankingScore?: number
}
export interface DBChannelSummary extends DBChannelShared, Omit<VideoChannelSummary, 'avatar' | 'avatars'> {
}
// Results from the search API
export interface EnhancedVideoChannel extends VideoChannel {
export interface APIVideoChannel extends VideoChannel {
videosCount: number
score: number

View File

@ -1,5 +1,4 @@
export interface IndexableDoc {
elasticSearchId: string
host: string
url: string
}

View File

@ -1,24 +1,26 @@
import { AccountSummary, VideoChannelSummary, VideoPlaylist } from '@peertube/peertube-types'
import { AdditionalActorAttributes } from './actor.model'
import { VideoPlaylist } from '@peertube/peertube-types'
import { IndexableDoc } from './indexable-doc.model'
import { DBAccountSummary } from './account.model'
import { DBChannelSummary } from './channel.model'
export interface IndexablePlaylist extends VideoPlaylist, IndexableDoc {
url: string
}
export interface DBPlaylist extends Omit<VideoPlaylist, 'isLocal'> {
indexedAt: Date
export interface DBPlaylist extends Omit<VideoPlaylist, 'isLocal' | 'createdAt' | 'updatedAt' | 'ownerAccount' | 'videoChannel'> {
indexedAt: number
createdAt: number
updatedAt: number
host: string
// Added by the query
score?: number
_rankingScore?: number
ownerAccount: AccountSummary & AdditionalActorAttributes
videoChannel: VideoChannelSummary & AdditionalActorAttributes
ownerAccount: DBAccountSummary
videoChannel: DBChannelSummary
}
// Results from the search API
export interface EnhancedPlaylist extends VideoPlaylist {
export interface APIPlaylist extends VideoPlaylist {
score: number
}

View File

@ -1,6 +1,7 @@
import { Account, AccountSummary, Video, VideoChannel, VideoChannelSummary, VideoDetails } from '@peertube/peertube-types'
import { AdditionalActorAttributes } from './actor.model'
import { Video, VideoDetails } from '@peertube/peertube-types'
import { IndexableDoc } from './indexable-doc.model'
import { DBChannel, DBChannelSummary } from './channel.model'
import { DBAccount, DBAccountSummary } from './account.model'
export interface IndexableVideo extends Video, IndexableDoc {
}
@ -8,28 +9,36 @@ export interface IndexableVideo extends Video, IndexableDoc {
export interface IndexableVideoDetails extends VideoDetails, IndexableDoc {
}
export interface DBVideoDetails extends Omit<VideoDetails, 'isLocal'> {
indexedAt: Date
type IgnoredVideoFields = 'isLocal' | 'createdAt' | 'updatedAt' | 'publishedAt' | 'originallyPublishedAt' | 'channel' | 'account'
interface DBVideoShared {
indexedAt: number
createdAt: number
updatedAt: number
publishedAt: number
originallyPublishedAt: number
host: string
url: string
account: Account & AdditionalActorAttributes
channel: VideoChannel & AdditionalActorAttributes
score?: number
searchableDescription: string
}
export interface DBVideo extends Omit<Video, 'isLocal'> {
indexedAt: Date
host: string
url: string
export interface DBVideo extends Omit<Video, IgnoredVideoFields>, DBVideoShared {
account: DBAccountSummary
channel: DBChannelSummary
}
account: AccountSummary & AdditionalActorAttributes
channel: VideoChannelSummary & AdditionalActorAttributes
export interface DBVideoDetails extends Omit<VideoDetails, IgnoredVideoFields>, DBVideoShared {
account: DBAccount
channel: DBChannel
_rankingScore?: number
}
// Results from the search API
export interface EnhancedVideo extends Video {
export interface APIVideo extends Video {
tags: VideoDetails['tags']
score: number

View File

@ -16,13 +16,11 @@
],
"types": [
"node"
],
"baseUrl": "."
]
},
"exclude": [
"node_modules",
"dist",
"client",
"PeerTube"
"client"
]
}

View File

@ -980,26 +980,6 @@
enabled "2.0.x"
kuler "^2.0.0"
"@elastic/elasticsearch@^8.2.1":
version "8.5.0"
resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-8.5.0.tgz#407aee0950a082ee76735a567f2571cf4301d4ea"
integrity sha512-iOgr/3zQi84WmPhAplnK2W13R89VXD2oc6WhlQmH3bARQwmI+De23ZJKBEn7bvuG/AHMAqasPXX7uJIiJa2MqQ==
dependencies:
"@elastic/transport" "^8.2.0"
tslib "^2.4.0"
"@elastic/transport@^8.2.0":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.3.1.tgz#e7569d7df35b03108ea7aa886113800245faa17f"
integrity sha512-jv/Yp2VLvv5tSMEOF8iGrtL2YsYHbpf4s+nDsItxUTLFTzuJGpnsB/xBlfsoT2kAYEnWHiSJuqrbRcpXEI/SEQ==
dependencies:
debug "^4.3.4"
hpagent "^1.0.0"
ms "^2.1.3"
secure-json-parse "^2.4.0"
tslib "^2.4.0"
undici "^5.5.1"
"@eslint/eslintrc@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.3.tgz#2b044ab39fdfa75b4688184f9e573ce3c5b0ff95"
@ -1202,7 +1182,7 @@
resolved "https://registry.yarnpkg.com/@types/async/-/async-3.2.16.tgz#7473aa015cf8a60584a94dc79b9203d465c32b41"
integrity sha512-jnlGp5Z/cAZ7JVYyLnSDuYJ+YyYm0o2yzL8Odv6ckWmGMow3j/P/wgfziybB044cXXA93lEuymJyxVR8Iz2amQ==
"@types/bluebird@*", "@types/bluebird@^3.5.33":
"@types/bluebird@^3.5.33":
version "3.5.38"
resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.38.tgz#7a671e66750ccd21c9fc9d264d0e1e5330bc9908"
integrity sha512-yR/Kxc0dd4FfwtEoLZMoqJbM/VE/W7hXn/MIjb+axcwag0iFmSPK7OBUZq1YWLynJUoWQkfUrI7T0HDqGApNSg==
@ -1242,13 +1222,6 @@
dependencies:
"@types/node" "*"
"@types/continuation-local-storage@*":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@types/continuation-local-storage/-/continuation-local-storage-3.2.4.tgz#655c8ffd9327aa60fb8ae773a5f2efbc973a7cbb"
integrity sha512-OT32vCVMymU1JMPKDeY0lX3cduAr0Pm/VwIbxygMeDS4lRcv57qYXn9bMwBRcRnEpXKBb/r4xYaZCARTZllP0A==
dependencies:
"@types/node" "*"
"@types/cookie@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
@ -1333,7 +1306,7 @@
dependencies:
"@types/node" "*"
"@types/lodash@*", "@types/lodash@^4.14.182":
"@types/lodash@^4.14.182":
version "4.14.191"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
@ -1428,16 +1401,6 @@
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91"
integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==
"@types/sequelize@^4.28.13":
version "4.28.14"
resolved "https://registry.yarnpkg.com/@types/sequelize/-/sequelize-4.28.14.tgz#70302689ddef09f5d472b548a14e5a150e690aff"
integrity sha512-O8lTJ8YPVVaoY9xjduchDlo0MOS3w262pro2H1QMuFIo/kc/p1elP/UxLOTP2wcVO2cFd6Gvghg9ZSAiJi0GLA==
dependencies:
"@types/bluebird" "*"
"@types/continuation-local-storage" "*"
"@types/lodash" "*"
"@types/validator" "*"
"@types/serve-static@*":
version "1.15.0"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
@ -1451,7 +1414,7 @@
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
"@types/validator@*", "@types/validator@^13.0.0", "@types/validator@^13.7.1", "@types/validator@^13.7.2":
"@types/validator@^13.0.0", "@types/validator@^13.7.1", "@types/validator@^13.7.2":
version "13.7.10"
resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.10.tgz#f9763dc0933f8324920afa9c0790308eedf55ca7"
integrity sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==
@ -1824,7 +1787,7 @@ bullmq@^1.87.0:
tslib "^2.0.0"
uuid "^9.0.0"
busboy@^1.0.0, busboy@^1.6.0:
busboy@^1.0.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
@ -2039,6 +2002,13 @@ cron-parser@^4.6.0:
dependencies:
luxon "^3.1.0"
cross-fetch@^3.1.6:
version "3.1.8"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82"
integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==
dependencies:
node-fetch "^2.6.12"
cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -3496,6 +3466,13 @@ media-typer@0.3.0:
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
meilisearch@^0.35.0:
version "0.35.0"
resolved "https://registry.yarnpkg.com/meilisearch/-/meilisearch-0.35.0.tgz#c94d5f266e39ad5ed5a666782e729805a4e8a956"
integrity sha512-gF1I6K5/Wpe7BWfjBnG+o19y/FIpJ9HbN+byON6CB9U3uE7qc6GvwUbjKOllh7LKXQVVxH/kCu7Jn0ODCUwqbQ==
dependencies:
cross-fetch "^3.1.6"
memoizee@^0.4.14:
version "0.4.15"
resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72"
@ -3634,7 +3611,7 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
ms@2.1.3, ms@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@ -3702,6 +3679,13 @@ next-tick@1, next-tick@^1.1.0:
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==
node-fetch@^2.6.12:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
dependencies:
whatwg-url "^5.0.0"
node-gyp-build-optional-packages@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17"
@ -4686,6 +4670,11 @@ tough-cookie@~2.5.0:
psl "^1.1.28"
punycode "^2.1.1"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
triple-beam@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
@ -4787,13 +4776,6 @@ unbox-primitive@^1.0.2:
has-symbols "^1.0.3"
which-boxed-primitive "^1.0.2"
undici@^5.5.1:
version "5.14.0"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.14.0.tgz#1169d0cdee06a4ffdd30810f6228d57998884d00"
integrity sha512-yJlHYw6yXPPsuOH0x2Ib1Km61vu4hLiRRQoafs+WUgX1vO64vgnxiCEN9dpIrhZyHFsai3F0AEj4P9zy19enEQ==
dependencies:
busboy "^1.6.0"
universalify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
@ -4855,6 +4837,19 @@ verror@1.10.0:
core-util-is "1.0.2"
extsprintf "^1.2.0"
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
dependencies:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"