hummingbird-me/kitsu-server

View on GitHub
app/services/anime_search_service.rb

Summary

Maintainability
B
7 hrs
Test Coverage
C
72%
# frozen_string_literal: true

class AnimeSearchService < TypesenseSearchService
  # Runs the search and returns the list of matching anime in order by their
  # relevance to the queries.
  #
  # @return [Array<Anime>] the list of matching anime
  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 ||= Anime.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 = TypesenseAnimeIndex.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, :episode_count)
    scope = apply_numeric_filter_for(scope, :episode_length)
    scope = apply_auto_filter_for(scope, :age_rating)
    scope = apply_auto_filter_for(scope, 'start_cour.season', filter_param: :season)
    scope = apply_numeric_filter_for(scope, 'start_cour.year', filter_param: :season_year)
    scope = apply_numeric_filter_for(scope, 'start_date.year', filter_param: :year)
    scope = apply_genres_filter_for(scope)
    scope = apply_categories_filter_for(scope)
    apply_streamers_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'
        [["#{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

  def apply_streamers_filter_for(scope)
    return scope if filters[:streamers].blank?

    streamer_ids = Streamer.by_name(filters[:streamers]).ids
    scope.filter(auto_query_for('streaming_sites', streamer_ids))
  end
end