Skip to content

Commit

Permalink
Improve main search design (#1273)
Browse files Browse the repository at this point in the history
This PR improves the whole main search page in various ways. Most
important points:

- Thumbnails are now on the left (I know a certain someone who will very
much welcome this change 😉 )
- The three different kinds of results were designed to be visually
easier to distinguish.
- Videos and series also show the realm path (if there is only one host
realm)
- The date is added to videos
- The matching portion in search results is highlighted, to show the
user immediately why a result appears.
- Note: This does not work for "creators" yet, due to a limitation in
the search index we use. We have to wait for an upstream fix.
- Note: metadata like description and title are truncated if too long.
If the match would appear in the parts that are cut off, it would be
still confusing for the user. Therefore, the metadata is truncated/cut
in such a way that the part with the match is shown. (Example: first
result for search "hadron myers")

This PR is a draft because I still want to do this:
- [x] Talk to our designer
- [x] Squash two mobile keyboard bugs
- [x] ~~Potentially change the "realm" icon~~ -> No
- [x] Fix bug where the last input on the search field can sometimes be
ignored
- [x] ~~Make highlight color stronger~~ -> See some comments below

In a separate, follow-up PR I still plan on doing these search-related
things:
- Use Meili federated search (improves ranking, potentially speed, and
prepares pagination)
- Improve ranking to make series appear before videos of that series
with title = series title when searching for series title
- Potentially merge series and page results when the page is a
"series-only-page" for that series
- Maybe: with multiple host realms, if only one exists that derives its
name from the series/event, use it for breadcrumbs & link.
- Finally decide about #500

Finally, filtering is still missing, which deserves a PR on its own.

---

Feedback is very welcome!


Fixes #470
Fixes #1066
Fixes #1065
  • Loading branch information
owi92 authored Nov 18, 2024
2 parents fe23b4e + 267ea28 commit 34ae6a8
Show file tree
Hide file tree
Showing 22 changed files with 1,104 additions and 502 deletions.
4 changes: 1 addition & 3 deletions backend/src/api/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ use crate::{
series::Series,
realm::Realm,
playlist::AuthorizedPlaylist,
search::SearchEvent,
search::{SearchEvent, SearchRealm, SearchSeries},
},
},
prelude::*,
search::Realm as SearchRealm,
search::Series as SearchSeries,
search::Playlist as SearchPlaylist,
db::types::ExtraMetadata,
};
Expand Down
5 changes: 3 additions & 2 deletions backend/src/api/model/known_roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::{
prelude::*,
db::util::{impl_from_db, select},
};
use super::search::{SearchUnavailable, SearchResults, handle_search_result};
use super::search::{handle_search_result, measure_search_duration, SearchResults, SearchUnavailable};


// ===== Groups ===============================================================
Expand Down Expand Up @@ -73,6 +73,7 @@ pub(crate) async fn search_known_users(
query: String,
context: &Context,
) -> ApiResult<KnownUsersSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand Down Expand Up @@ -127,5 +128,5 @@ pub(crate) async fn search_known_users(
items.extend(results.hits.into_iter().map(|h| h.result));
}

Ok(KnownUsersSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(KnownUsersSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}
59 changes: 45 additions & 14 deletions backend/src/api/model/search/event.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use chrono::{DateTime, Utc};
use juniper::GraphQLObject;
use meilisearch_sdk::MatchRange;

use crate::{
api::{Context, Id, Node, NodeValue},
db::types::TextAssetType,
search,
};
use super::{field_matches_for, match_ranges_for, ByteSpan, SearchRealm};


#[derive(Debug, GraphQLObject)]
Expand All @@ -25,14 +25,17 @@ pub(crate) struct SearchEvent {
pub end_time: Option<DateTime<Utc>>,
pub is_live: bool,
pub audio_only: bool,
pub host_realms: Vec<search::Realm>,
pub host_realms: Vec<SearchRealm>,
pub text_matches: Vec<TextMatch>,
pub matches: SearchEventMatches,
}

#[derive(Debug, GraphQLObject)]
pub struct ByteSpan {
pub start: i32,
pub len: i32,
#[derive(Debug, GraphQLObject, Default)]
pub struct SearchEventMatches {
title: Vec<ByteSpan>,
description: Vec<ByteSpan>,
series_title: Vec<ByteSpan>,
// TODO: creators
}

/// A match inside an event's texts while searching.
Expand Down Expand Up @@ -62,15 +65,40 @@ impl Node for SearchEvent {
}

impl SearchEvent {
pub(crate) fn new(
src: search::Event,
slide_matches: &[MatchRange],
caption_matches: &[MatchRange],
) -> Self {
pub(crate) fn without_matches(src: search::Event) -> Self {
Self::new_inner(src, vec![], SearchEventMatches::default())
}

pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>) -> Self {
let match_positions = hit.matches_position.as_ref();
let src = hit.result;

let mut text_matches = Vec::new();
src.slide_texts.resolve_matches(slide_matches, &mut text_matches, TextAssetType::SlideText);
src.caption_texts.resolve_matches(caption_matches, &mut text_matches, TextAssetType::Caption);
src.slide_texts.resolve_matches(
match_ranges_for(match_positions, "slide_texts.texts"),
&mut text_matches,
TextAssetType::SlideText,
);
src.caption_texts.resolve_matches(
match_ranges_for(match_positions, "caption_texts.texts"),
&mut text_matches,
TextAssetType::Caption,
);

let matches = SearchEventMatches {
title: field_matches_for(match_positions, "title"),
description: field_matches_for(match_positions, "description"),
series_title: field_matches_for(match_positions, "series_title"),
};

Self::new_inner(src, text_matches, matches)
}

fn new_inner(
src: search::Event,
text_matches: Vec<TextMatch>,
matches: SearchEventMatches,
) -> Self {
Self {
id: Id::search_event(src.id.0),
series_id: src.series_id.map(|id| Id::search_series(id.0)),
Expand All @@ -85,8 +113,11 @@ impl SearchEvent {
end_time: src.end_time,
is_live: src.is_live,
audio_only: src.audio_only,
host_realms: src.host_realms,
host_realms: src.host_realms.into_iter()
.map(|r| SearchRealm::without_matches(r))
.collect(),
text_matches,
matches,
}
}
}
132 changes: 84 additions & 48 deletions backend/src/api/model/search/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use chrono::{DateTime, Utc};
use juniper::GraphQLObject;
use meilisearch_sdk::MatchRange;
use once_cell::sync::Lazy;
use regex::Regex;
use std::{borrow::Cow, fmt};
use std::{borrow::Cow, collections::HashMap, fmt, time::Instant};

use crate::{
api::{
Expand All @@ -22,7 +23,11 @@ mod realm;
mod series;
mod playlist;

pub(crate) use self::event::{SearchEvent, TextMatch, ByteSpan};
pub(crate) use self::{
event::{SearchEvent, TextMatch},
realm::SearchRealm,
series::SearchSeries,
};


/// Marker type to signal that the search functionality is unavailable for some
Expand Down Expand Up @@ -50,37 +55,37 @@ pub(crate) enum SearchOutcome {
pub(crate) struct SearchResults<T> {
pub(crate) items: Vec<T>,
pub(crate) total_hits: usize,
pub(crate) duration: i32,
}

#[juniper::graphql_object(Context = Context)]
impl SearchResults<NodeValue> {
fn items(&self) -> &[NodeValue] {
&self.items
}
fn total_hits(&self) -> i32 {
self.total_hits as i32
}
macro_rules! make_search_results_object {
($name:literal, $ty:ty) => {
#[juniper::graphql_object(Context = Context, name = $name)]
impl SearchResults<$ty> {
fn items(&self) -> &[$ty] {
&self.items
}
fn total_hits(&self) -> i32 {
self.total_hits as i32
}
/// How long searching took in ms.
fn duration(&self) -> i32 {
self.duration
}
}
};
}

#[juniper::graphql_object(Context = Context, name = "EventSearchResults")]
impl SearchResults<SearchEvent> {
fn items(&self) -> &[SearchEvent] {
&self.items
}
}
make_search_results_object!("SearchResults", NodeValue);
make_search_results_object!("EventSearchResults", SearchEvent);
make_search_results_object!("SeriesSearchResults", SearchSeries);
make_search_results_object!("PlaylistSearchResults", search::Playlist);

#[juniper::graphql_object(Context = Context, name = "SeriesSearchResults")]
impl SearchResults<search::Series> {
fn items(&self) -> &[search::Series] {
&self.items
}
}

#[juniper::graphql_object(Context = Context, name = "PlaylistSearchResults")]
impl SearchResults<search::Playlist> {
fn items(&self) -> &[search::Playlist] {
&self.items
}
#[derive(Debug, GraphQLObject)]
pub struct ByteSpan {
pub start: i32,
pub len: i32,
}

#[derive(Debug, Clone, Copy, juniper::GraphQLEnum)]
Expand Down Expand Up @@ -150,6 +155,7 @@ pub(crate) async fn perform(
filters: Filters,
context: &Context,
) -> ApiResult<SearchOutcome> {
let elapsed_time = measure_search_duration();
if user_query.is_empty() {
return Ok(SearchOutcome::EmptyQuery(EmptyQuery));
}
Expand All @@ -166,12 +172,16 @@ pub(crate) async fn perform(
.await?
.map(|row| {
let e = search::Event::from_row_start(&row);
SearchEvent::new(e, &[], &[]).into()
SearchEvent::without_matches(e).into()
})
.into_iter()
.collect();
let total_hits = items.len();
return Ok(SearchOutcome::Results(SearchResults { items, total_hits }));
return Ok(SearchOutcome::Results(SearchResults {
items,
total_hits,
duration: elapsed_time(),
}));
}


Expand Down Expand Up @@ -233,12 +243,16 @@ pub(crate) async fn perform(
// https://github.com/orgs/meilisearch/discussions/489#discussioncomment-6160361
let events = event_results.hits.into_iter().map(|result| {
let score = result.ranking_score;
(NodeValue::from(hit_to_search_event(result)), score)
(NodeValue::from(SearchEvent::new(result)), score)
});
let series = series_results.hits.into_iter().map(|result| {
let score = result.ranking_score;
(NodeValue::from(SearchSeries::new(result, context)), score)
});
let realms = realm_results.hits.into_iter().map(|result| {
let score = result.ranking_score;
(NodeValue::from(SearchRealm::new(result)), score)
});
let series = series_results.hits.into_iter()
.map(|result| (NodeValue::from(result.result), result.ranking_score));
let realms = realm_results.hits.into_iter()
.map(|result| (NodeValue::from(result.result), result.ranking_score));

let mut merged: Vec<(NodeValue, Option<f64>)> = Vec::new();
let total_hits: usize;
Expand Down Expand Up @@ -274,7 +288,11 @@ pub(crate) async fn perform(
merged.sort_unstable_by(|(_, score0), (_, score1)| score1.unwrap().total_cmp(&score0.unwrap()));

let items = merged.into_iter().map(|(node, _)| node).collect();
Ok(SearchOutcome::Results(SearchResults { items, total_hits }))
Ok(SearchOutcome::Results(SearchResults {
items,
total_hits,
duration: elapsed_time(),
}))
}

fn looks_like_opencast_uuid(query: &str) -> bool {
Expand All @@ -301,6 +319,7 @@ pub(crate) async fn all_events(
writable_only: bool,
context: &Context,
) -> ApiResult<EventSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand All @@ -327,25 +346,26 @@ pub(crate) async fn all_events(
}
let res = query.execute::<search::Event>().await;
let results = handle_search_result!(res, EventSearchOutcome);
let items = results.hits.into_iter().map(|h| hit_to_search_event(h)).collect();
let items = results.hits.into_iter().map(|h| SearchEvent::new(h)).collect();
let total_hits = results.estimated_total_hits.unwrap_or(0);

Ok(EventSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(EventSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}

// See `EventSearchOutcome` for additional information.
#[derive(juniper::GraphQLUnion)]
#[graphql(Context = Context)]
pub(crate) enum SeriesSearchOutcome {
SearchUnavailable(SearchUnavailable),
Results(SearchResults<search::Series>),
Results(SearchResults<SearchSeries>),
}

pub(crate) async fn all_series(
user_query: &str,
writable_only: bool,
context: &Context,
) -> ApiResult<SeriesSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand Down Expand Up @@ -378,10 +398,10 @@ pub(crate) async fn all_series(
}
let res = query.execute::<search::Series>().await;
let results = handle_search_result!(res, SeriesSearchOutcome);
let items = results.hits.into_iter().map(|h| h.result).collect();
let items = results.hits.into_iter().map(|h| SearchSeries::new(h, context)).collect();
let total_hits = results.estimated_total_hits.unwrap_or(0);

Ok(SeriesSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(SeriesSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}

#[derive(juniper::GraphQLUnion)]
Expand All @@ -396,6 +416,7 @@ pub(crate) async fn all_playlists(
writable_only: bool,
context: &Context,
) -> ApiResult<PlaylistSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand Down Expand Up @@ -425,7 +446,7 @@ pub(crate) async fn all_playlists(
let items = results.hits.into_iter().map(|h| h.result).collect();
let total_hits = results.estimated_total_hits.unwrap_or(0);

Ok(PlaylistSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(PlaylistSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}

// TODO: replace usages of this and remove this.
Expand Down Expand Up @@ -533,13 +554,28 @@ impl fmt::Display for Filter {
}
}

fn hit_to_search_event(
hit: meilisearch_sdk::SearchResult<search::Event>,
) -> SearchEvent {
let get_matches = |key: &str| hit.matches_position.as_ref()
.and_then(|matches| matches.get(key))

fn match_ranges_for<'a>(
match_positions: Option<&'a HashMap<String, Vec<MatchRange>>>,
field: &str,
) -> &'a [MatchRange] {
match_positions
.and_then(|m| m.get(field))
.map(|v| v.as_slice())
.unwrap_or_default();
.unwrap_or_default()
}

fn field_matches_for(
match_positions: Option<&HashMap<String, Vec<MatchRange>>>,
field: &str,
) -> Vec<ByteSpan> {
match_ranges_for(match_positions, field).iter()
.map(|m| ByteSpan { start: m.start as i32, len: m.length as i32 })
.take(8) // The frontend can only show a limited number anyway
.collect()
}

SearchEvent::new(hit.result, get_matches("slide_texts.texts"), get_matches("caption_texts.texts"))
pub(crate) fn measure_search_duration() -> impl FnOnce() -> i32 {
let start = Instant::now();
move || start.elapsed().as_millis() as i32
}
Loading

0 comments on commit 34ae6a8

Please sign in to comment.