require 'net/http' require 'json' require 'uri' class IgdbService BASE_URL = "https://api.igdb.com/v4" TOKEN_URL = "https://id.twitch.tv/oauth2/token" CACHE_KEY = "igdb_access_token" class ApiError < StandardError; end class RateLimitError < StandardError; end def initialize @client_id = ENV.fetch("IGDB_CLIENT_ID") @client_secret = ENV.fetch("IGDB_CLIENT_SECRET") @access_token = get_or_refresh_token end # Search for games by title and platform # Returns array of matches with confidence scores def search_game(title, platform = nil, limit = 3) platform_filter = platform_filter_query(platform) query = <<~QUERY search "#{sanitize_search_term(title)}"; fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name; #{platform_filter} limit #{limit}; QUERY results = post("/games", query) return [] if results.empty? # Calculate confidence scores and format results results.map do |game| confidence = calculate_confidence(title, game["name"], platform, game["platforms"]) format_game_result(game, confidence) end.sort_by { |g| -g[:confidence_score] } rescue => e Rails.logger.error("IGDB search error: #{e.message}") [] end # Get specific game by IGDB ID def get_game(igdb_id) query = <<~QUERY fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name; where id = #{igdb_id}; QUERY results = post("/games", query) results.first end private # Get cached token or generate a new one def get_or_refresh_token # Check if we have a cached token cached_token = Rails.cache.read(CACHE_KEY) return cached_token if cached_token # Generate new token generate_access_token end # Generate a new access token from Twitch def generate_access_token uri = URI(TOKEN_URL) uri.query = URI.encode_www_form({ client_id: @client_id, client_secret: @client_secret, grant_type: 'client_credentials' }) response = Net::HTTP.post(uri, '') if response.code.to_i == 200 data = JSON.parse(response.body) token = data['access_token'] expires_in = data['expires_in'] # seconds until expiration (usually ~5 million seconds / ~60 days) # Cache token for 90% of its lifetime to be safe cache_duration = (expires_in * 0.9).to_i Rails.cache.write(CACHE_KEY, token, expires_in: cache_duration) Rails.logger.info("Generated new IGDB access token (expires in #{expires_in / 86400} days)") token else raise ApiError, "Failed to get IGDB token: #{response.code} - #{response.body}" end rescue => e Rails.logger.error("Failed to generate IGDB token: #{e.message}") raise ApiError, "Cannot authenticate with IGDB: #{e.message}" end def post(endpoint, body, retry_count = 0) uri = URI("#{BASE_URL}#{endpoint}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true request = Net::HTTP::Post.new(uri.path) request["Client-ID"] = @client_id request["Authorization"] = "Bearer #{@access_token}" request["Content-Type"] = "text/plain" request.body = body Rails.logger.info("IGDB Request: #{body}") # Rate limiting: sleep to avoid hitting limits (4 req/sec) sleep(0.3) response = http.request(request) Rails.logger.info("IGDB Response: #{response.code} - #{response.body[0..200]}") case response.code.to_i when 200 JSON.parse(response.body) when 401 # Token expired or invalid - refresh and retry once if retry_count == 0 Rails.logger.warn("IGDB token invalid, refreshing...") Rails.cache.delete(CACHE_KEY) @access_token = generate_access_token return post(endpoint, body, retry_count + 1) else Rails.logger.error("IGDB authentication failed after token refresh") raise ApiError, "IGDB authentication failed" end when 429 raise RateLimitError, "IGDB rate limit exceeded" when 400..499 Rails.logger.error("IGDB API error: #{response.code} - #{response.body}") raise ApiError, "IGDB API error: #{response.code}" else Rails.logger.error("IGDB unexpected response: #{response.code} - #{response.body}") [] end rescue JSON::ParserError => e Rails.logger.error("IGDB JSON parse error: #{e.message}") [] end def platform_filter_query(platform) return "" unless platform igdb_platform_id = IgdbPlatformMapping.igdb_id_for_platform(platform) return "" unless igdb_platform_id "where platforms = (#{igdb_platform_id});" end def sanitize_search_term(term) # Escape quotes and remove special characters that might break the query term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "") end def calculate_confidence(search_title, result_title, search_platform, result_platforms) score = 0.0 # Title similarity (0-70 points) search_clean = search_title.downcase.strip result_clean = result_title.downcase.strip if search_clean == result_clean score += 70 elsif result_clean.include?(search_clean) || search_clean.include?(result_clean) score += 50 else # Levenshtein distance or similar could be used here # For now, check if major words match search_words = search_clean.split(/\W+/) result_words = result_clean.split(/\W+/) common_words = search_words & result_words score += (common_words.length.to_f / search_words.length) * 40 end # Platform match (0-30 points) if search_platform && result_platforms platform_names = result_platforms.map { |p| p["name"].downcase } igdb_platform_id = IgdbPlatformMapping.igdb_id_for_platform(search_platform) # Check if our platform is in the result platforms if igdb_platform_id # Exact platform match score += 30 elsif platform_names.any? { |name| name.include?(search_platform.name.downcase) } # Partial platform match score += 20 end end score.round(2) end def format_game_result(game, confidence) cover_id = game.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "") platform_name = if game["platforms"]&.any? game["platforms"].first["name"] else "Unknown" end release_date = if game["first_release_date"] Time.at(game["first_release_date"]).to_date else nil end # Extract genre names genre_names = if game["genres"]&.any? game["genres"].map { |g| g["name"] } else [] end { igdb_id: game["id"], name: game["name"], slug: game["slug"], cover_url: cover_id, summary: game["summary"], release_date: release_date, release_year: release_date&.year, platform_name: platform_name, genres: genre_names, confidence_score: confidence } end end