mirror of
https://github.com/ryankazokas/turbovault-app.git
synced 2026-04-16 21:02:52 +00:00
236 lines
7.0 KiB
Ruby
236 lines
7.0 KiB
Ruby
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
|