Files
turbovault-app/app/services/igdb_service.rb
2026-03-29 02:37:49 -04:00

249 lines
7.7 KiB
Ruby

require "net/http"
require "json"
require "uri"
# typed: true
class IgdbService
extend T::Sig
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
sig { void }
def initialize
@client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String)
@client_secret = T.let(ENV.fetch("IGDB_CLIENT_SECRET"), String)
@access_token = T.let(get_or_refresh_token, String)
end
# Search for games by title and platform
# Returns array of matches with confidence scores
sig { params(title: String, platform: T.nilable(String), limit: Integer).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
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
sig { params(igdb_id: Integer).returns(T.nilable(T::Hash[String, T.untyped])) }
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
sig { returns(String) }
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
sig { returns(String) }
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
sig { params(endpoint: String, body: String, retry_count: Integer).returns(T::Array[T::Hash[String, T.untyped]]) }
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
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
sig { params(platform: T.nilable(String)).returns(String) }
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
sig { params(term: String).returns(String) }
def sanitize_search_term(term)
# Escape quotes and remove special characters that might break the query
term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "")
end
sig { params(search_title: String, result_title: String, search_platform: T.nilable(String), result_platforms: T.nilable(T::Array[T::Hash[String, T.untyped]])).returns(Float) }
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
sig { params(game: T::Hash[String, T.untyped], confidence: Float).returns(T::Hash[Symbol, T.untyped]) }
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