app/services/manga_search_service.rb
# frozen_string_literal: true
class MangaSearchService < TypesenseSearchService
# Runs the search and returns the list of matching manga in order by their
# relevance to the queries.
#
# @return [Array<Manga>] the list of matching manga
def to_a
result_ids.map do |id|
result_media[id.to_i]
end
end
def query_results
@query_results ||= query.include_fields(:id).load
end
def sfw
@sfw = true
self
end
private
def result_media
@result_media ||= Manga.find(result_ids).index_by(&:id)
end
def result_ids
@result_ids ||= query_results.hits.map { |res| res.document['id'] }
end
def query
@query ||= begin
query = TypesenseMangaIndex.search(
query: filters[:text]&.join(' ') || '',
query_by: {
'canonical_title' => 100,
'titles.*' => 90,
'alternative_titles' => 90,
'descriptions.*' => 80
}
).set(
prioritize_token_position: true,
prioritize_num_matching_fields: false
)
query = apply_filters_to(query)
query = apply_order_to(query)
query = apply_page_to(query)
query = apply_per_to(query)
query
end
end
def apply_filters_to(scope)
scope = apply_sfw_filter_for(scope)
scope = apply_numeric_filter_for(scope, :average_rating)
scope = apply_numeric_filter_for(scope, :user_count)
scope = apply_auto_filter_for(scope, :subtype)
scope = apply_status_filter_for(scope)
scope = apply_numeric_filter_for(scope, :chapter_count)
scope = apply_auto_filter_for(scope, :age_rating)
scope = apply_numeric_filter_for(scope, 'start_date.year', filter_param: :year)
scope = apply_genres_filter_for(scope)
apply_categories_filter_for(scope)
end
def apply_order_to(scope)
return scope unless orders
# Replace _text_match with _text_match(buckets: 6),user_count in the same direction
# This generally gives significantly better results for media searches
improved_orders = orders.flat_map do |field, direction|
case field
when '_text_match'
[['_text_match(buckets: 6)', direction], ['user_count', direction]]
when 'start_date', 'created_at'
[["#{field}.timestamp", direction]]
when 'popularity_rank'
# Invert the direction for popularity_rank
[['user_count', direction == :desc ? :asc : :desc]]
when 'rating_rank'
# Invert the direction for rating_rank
[['average_rating', direction == :desc ? :asc : :desc]]
else
[[field, direction]]
end
end
scope.sort(improved_orders.to_h)
end
def apply_sfw_filter_for(scope)
return scope unless @sfw
scope.filter('age_rating:!=[R18]')
end
def apply_status_filter_for(scope)
return scope if filters[:status].blank?
statuses = filters[:status].map do |status|
case status
when 'past'
"(start_date.timestamp:<=#{Time.now.to_i})"
when 'finished'
<<~FILTERS.squish
(start_date.timestamp:<=#{Time.now.to_i} &&
end_date.timestamp:<#{Time.now.to_i})
FILTERS
when 'current'
<<~FILTERS.squish
(start_date.timestamp:<=#{Time.now.to_i} &&
(end_date.timestamp:>=#{Time.now.to_i} || end_date.is_null:true))
FILTERS
when 'future'
"(start_date.timestamp:>#{Time.now.to_i})"
when 'upcoming'
<<~FILTERS.squish
(start_date.timestamp:>#{Time.now.to_i} &&
start_date.timestamp:<=#{3.months.from_now.to_i})
FILTERS
when 'unreleased'
"(start_date.timestamp:>#{3.months.from_now.to_i})"
when 'tba'
'(start_date.is_null:true && end_date.is_null:true)'
end
end
scope.filter(statuses.join(' || '))
end
def apply_genres_filter_for(scope)
return scope if filters[:genres].blank?
genre_ids = Genre.where(slug: filters[:genres]).ids
# Implemented as a set of AND filters
scope.filter(genre_ids.map { |id| "genres:=#{id}" })
end
def apply_categories_filter_for(scope)
return scope if filters[:categories].blank?
category_ids = Category.where(slug: filters[:categories]).ids
# Implemented as a set of AND filters
scope.filter(category_ids.map { |id| "categories:=#{id}" })
end
end