updated elastic autocomplete to include persons, also adds debounce & clearer handling of response

This commit is contained in:
2025-01-11 13:38:06 +01:00
parent 25da19eaf5
commit fb3b4c8f7d
4 changed files with 155 additions and 56 deletions

View File

@@ -4,7 +4,7 @@ import type {
IRequestSubmitResponse IRequestSubmitResponse
} from "./interfaces/IRequestResponse"; } from "./interfaces/IRequestResponse";
const { ELASTIC, ELASTIC_INDEX } = process.env; const { ELASTIC, ELASTIC_INDEX, ELASTIC_APIKEY } = process.env;
const API_HOSTNAME = window.location.origin; const API_HOSTNAME = window.location.origin;
// - - - TMDB - - - // - - - TMDB - - -
@@ -430,9 +430,13 @@ const unlinkPlexAccount = () => {
// - - - User graphs - - - // - - - User graphs - - -
const fetchGraphData = (urlPath, days, chartType) => { const fetchGraphData = async (
urlPath: string,
days: number,
chartType: string
) => {
const url = new URL(`/api/v1/user/${urlPath}`, API_HOSTNAME); const url = new URL(`/api/v1/user/${urlPath}`, API_HOSTNAME);
url.searchParams.append("days", days); url.searchParams.append("days", String(days));
url.searchParams.append("y_axis", chartType); url.searchParams.append("y_axis", chartType);
return fetch(url.href).then(resp => { return fetch(url.href).then(resp => {
@@ -447,7 +451,7 @@ const fetchGraphData = (urlPath, days, chartType) => {
// - - - Random emoji - - - // - - - Random emoji - - -
const getEmoji = () => { const getEmoji = async () => {
const url = new URL("/api/v1/emoji", API_HOSTNAME); const url = new URL("/api/v1/emoji", API_HOSTNAME);
return fetch(url.href) return fetch(url.href)
@@ -468,33 +472,58 @@ const getEmoji = () => {
* @param {string} query * @param {string} query
* @returns {object} List of movies and shows matching query * @returns {object} List of movies and shows matching query
*/ */
const elasticSearchMoviesAndShows = (query, count = 22) => { const elasticSearchMoviesAndShows = (query: string, count = 22) => {
const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC); const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC);
const body = { const body = {
sort: [{ popularity: { order: "desc" } }, "_score"], sort: [{ popularity: { order: "desc" } }, "_score"],
size: count,
query: { query: {
bool: { multi_match: {
should: [ query,
{ fields: ["name", "original_title", "original_name"],
match_phrase_prefix: { type: "phrase_prefix",
original_name: query tie_breaker: 0.3
} }
}, },
{ suggest: {
match_phrase_prefix: { text: query,
original_title: query "person-suggest": {
prefix: query,
completion: {
field: "name.completion",
fuzzy: {
fuzziness: "AUTO"
} }
} }
]
}
}, },
size: count "movie-suggest": {
prefix: query,
completion: {
field: "original_title.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
},
"show-suggest": {
prefix: query,
completion: {
field: "original_name.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
}
}
}; };
const options = { const options = {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
Authorization: `ApiKey ${ELASTIC_APIKEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(body) body: JSON.stringify(body)
}; };

View File

@@ -10,6 +10,7 @@
> >
<IconMovie v-if="result.type == 'movie'" class="type-icon" /> <IconMovie v-if="result.type == 'movie'" class="type-icon" />
<IconShow v-if="result.type == 'show'" class="type-icon" /> <IconShow v-if="result.type == 'show'" class="type-icon" />
<IconPerson v-if="result.type == 'person'" class="type-icon" />
<span class="title">{{ result.title }}</span> <span class="title">{{ result.title }}</span>
</li> </li>
@@ -23,18 +24,25 @@
</transition> </transition>
</template> </template>
<!--
Searches Elasticsearch for results based on changes to `query`.
-->
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import IconMovie from "@/icons/IconMovie.vue"; import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue"; import IconShow from "@/icons/IconShow.vue";
import IconPerson from "@/icons/IconPerson.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { elasticSearchMoviesAndShows } from "../../api"; import { elasticSearchMoviesAndShows } from "../../api";
import { MediaTypes } from "../../interfaces/IList"; import { MediaTypes } from "../../interfaces/IList";
import { Index } from "../../interfaces/IAutocompleteSearch";
import type { import type {
IAutocompleteResult, IAutocompleteResult,
IAutocompleteSearchResults IAutocompleteSearchResults,
Hit,
Option,
Source
} from "../../interfaces/IAutocompleteSearch"; } from "../../interfaces/IAutocompleteSearch";
interface Props { interface Props {
@@ -48,6 +56,7 @@
} }
const numberOfResults = 10; const numberOfResults = 10;
let timeoutId = null;
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
const store = useStore(); const store = useStore();
@@ -55,23 +64,9 @@
const searchResults: Ref<Array<IAutocompleteResult>> = ref([]); const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
const keyboardNavigationIndex: Ref<number> = ref(0); const keyboardNavigationIndex: Ref<number> = ref(0);
watch( function removeDuplicates(_searchResults: Array<IAutocompleteResult>) {
() => props.query,
newQuery => {
if (newQuery?.length > 0)
fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */
}
);
function openPopup(result) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
function removeDuplicates(_searchResults) {
const filteredResults = []; const filteredResults = [];
_searchResults.forEach(result => { _searchResults.forEach((result: IAutocompleteResult) => {
if (result === undefined) return; if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter( const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id filterItem => filterItem.id === result.id
@@ -86,34 +81,43 @@
return filteredResults; return filteredResults;
} }
function elasticIndexToMediaType(index: Index): MediaTypes { function convertMediaType(type: string | null): MediaTypes | null {
if (index === Index.Movies) return MediaTypes.Movie; if (type === "movie") return MediaTypes.Movie;
if (index === Index.Shows) return MediaTypes.Show;
if (type === "tv_series") return MediaTypes.Show;
if (type === "person") return MediaTypes.Person;
return null; return null;
} }
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) { function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const data = elasticResponse.hits.hits; const elasticResults = elasticResponse.hits.hits;
const suggestResults = elasticResponse.suggest["movie-suggest"][0].options;
let data: Array<Source> = elasticResults.map((el: Hit) => el._source);
data = data.concat(suggestResults.map((el: Option) => el._source));
// data = data.concat(elasticResponse['suggest']['person-suggest'][0]['options'])
// data = data.concat(elasticResponse['suggest']['show-suggest'][0]['options'])
data = data.sort((a, b) => (a.popularity < b.popularity ? 1 : -1));
const results: Array<IAutocompleteResult> = []; const results: Array<IAutocompleteResult> = [];
data.forEach(item => { data.forEach(item => {
if (!Object.values(Index).includes(item._index)) {
return;
}
results.push({ results.push({
title: item._source?.original_name || item._source.original_title, title: item?.original_name || item?.original_title || item?.name,
id: item._source.id, id: item.id,
adult: item._source.adult, adult: item.adult,
type: elasticIndexToMediaType(item._index) type: convertMediaType(item?.type)
}); });
}); });
return removeDuplicates(results).map((el, index) => { return removeDuplicates(results)
.map((el, index) => {
return { ...el, index }; return { ...el, index };
}); })
.slice(0, 10);
} }
function fetchAutocompleteResults() { function fetchAutocompleteResults() {
@@ -123,11 +127,34 @@
elasticSearchMoviesAndShows(props.query, numberOfResults) elasticSearchMoviesAndShows(props.query, numberOfResults)
.then(elasticResponse => parseElasticResponse(elasticResponse)) .then(elasticResponse => parseElasticResponse(elasticResponse))
.then(_searchResults => { .then(_searchResults => {
console.log(_searchResults);
emit("update:results", _searchResults); emit("update:results", _searchResults);
searchResults.value = _searchResults; searchResults.value = _searchResults;
}); });
} }
const debounce = (callback: () => void, wait: number) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback();
}, wait);
};
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0) {
debounce(fetchAutocompleteResults, 150);
}
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
// on load functions // on load functions
fetchAutocompleteResults(); fetchAutocompleteResults();
// end on load functions // end on load functions

View File

@@ -42,6 +42,18 @@
</div> </div>
</template> </template>
<!-- Handles constructing markup and state for dropdown.
Markup:
Consist of: search icon, input & close button.
State:
State is passing input variable `query` to dropdown and carrying state
of selected dropdown element as variable `index`. This is because
index is manipulated based on arrow key events from same input as
the `query`.
-->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
@@ -51,6 +63,7 @@
import IconClose from "@/icons/IconClose.vue"; import IconClose from "@/icons/IconClose.vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { MediaTypes } from "../../interfaces/IList"; import type { MediaTypes } from "../../interfaces/IList";
import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
interface ISearchResult { interface ISearchResult {
title: string; title: string;
@@ -66,7 +79,7 @@
const query: Ref<string> = ref(null); const query: Ref<string> = ref(null);
const disabled: Ref<boolean> = ref(false); const disabled: Ref<boolean> = ref(false);
const dropdownIndex: Ref<number> = ref(-1); const dropdownIndex: Ref<number> = ref(-1);
const dropdownResults: Ref<ISearchResult[]> = ref([]); const dropdownResults: Ref<IAutocompleteResult[]> = ref([]);
const inputIsActive: Ref<boolean> = ref(false); const inputIsActive: Ref<boolean> = ref(false);
const inputElement: Ref<HTMLInputElement> = ref(null); const inputElement: Ref<HTMLInputElement> = ref(null);
@@ -85,8 +98,13 @@
query.value = decodeURIComponent(params.get("query")); query.value = decodeURIComponent(params.get("query"));
} }
const { ELASTIC } = process.env; const { ELASTIC, ELASTIC_APIKEY } = process.env;
if (ELASTIC === undefined || ELASTIC === "") { if (
ELASTIC === undefined ||
ELASTIC === "" ||
ELASTIC_APIKEY === undefined ||
ELASTIC_APIKEY === ""
) {
disabled.value = true; disabled.value = true;
} }
@@ -145,6 +163,7 @@
function handleSubmit() { function handleSubmit() {
if (!query.value || query.value.length === 0) return; if (!query.value || query.value.length === 0) return;
// if index is set, navigation has happened. Open popup else search
if (dropdownIndex.value >= 0) { if (dropdownIndex.value >= 0) {
const resultItem = dropdownResults.value[dropdownIndex.value]; const resultItem = dropdownResults.value[dropdownIndex.value];

View File

@@ -13,6 +13,7 @@ export interface IAutocompleteSearchResults {
timed_out: boolean; timed_out: boolean;
_shards: Shards; _shards: Shards;
hits: Hits; hits: Hits;
suggest: Suggest;
} }
export interface Shards { export interface Shards {
@@ -37,6 +38,27 @@ export interface Hit {
sort: number[]; sort: number[];
} }
export interface Suggest {
"movie-suggest": SuggestOptions[];
"person-suggest": SuggestOptions[];
"show-suggest": SuggestOptions[];
}
export interface SuggestOptions {
text: string;
offset: number;
length: number;
options: Option[];
}
export interface Option {
text: string;
_index: string;
_id: string;
_score: number;
_source: Source;
}
export enum Index { export enum Index {
Movies = "movies", Movies = "movies",
Shows = "shows" Shows = "shows"
@@ -57,6 +79,8 @@ export interface Source {
agent: Agent; agent: Agent;
original_title: string; original_title: string;
original_name?: string; original_name?: string;
name?: string;
type?: MediaTypes;
} }
export interface Agent { export interface Agent {