mirror of
https://github.com/ryankazokas/turbovault-app.git
synced 2026-04-16 23:22:53 +00:00
Moving to github
This commit is contained in:
235
app/services/igdb_service.rb
Normal file
235
app/services/igdb_service.rb
Normal file
@@ -0,0 +1,235 @@
|
||||
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
|
||||
Reference in New Issue
Block a user