From c8f7204b08b88d9d96d6c70967c4fd604c8900fe Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 7 Jan 2026 23:59:20 -0800 Subject: [PATCH] Add game_id to raids (#208) * add game_id to raids * rename game_id to enemy_id, add summon_id * add raid image downloader * add download endpoints for raid images - download_image action for single image download (icon/thumbnail) - download_images action to queue async download job - download_status action to check job progress * add quest_id and lobby/background image sizes for raids - add quest_id column to raids table - add lobby and background image downloads using quest_id - lobby uses quest_id with "1" appended - background uses quest_id for treasureraid directory --- app/blueprints/api/v1/raid_blueprint.rb | 2 +- app/controllers/api/v1/raids_controller.rb | 86 +++++++- app/jobs/download_raid_images_job.rb | 87 ++++++++ app/services/raid_image_download_service.rb | 77 +++++++ config/routes.rb | 8 +- .../20260106102045_add_game_id_to_raids.rb | 5 + ...051_rename_game_id_to_enemy_id_on_raids.rb | 5 + .../20260106104115_add_summon_id_to_raids.rb | 5 + .../20260106114730_add_quest_id_to_raids.rb | 5 + db/schema.rb | 5 +- lib/granblue/downloaders/raid_downloader.rb | 191 ++++++++++++++++++ 11 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 app/jobs/download_raid_images_job.rb create mode 100644 app/services/raid_image_download_service.rb create mode 100644 db/migrate/20260106102045_add_game_id_to_raids.rb create mode 100644 db/migrate/20260106104051_rename_game_id_to_enemy_id_on_raids.rb create mode 100644 db/migrate/20260106104115_add_summon_id_to_raids.rb create mode 100644 db/migrate/20260106114730_add_quest_id_to_raids.rb create mode 100644 lib/granblue/downloaders/raid_downloader.rb diff --git a/app/blueprints/api/v1/raid_blueprint.rb b/app/blueprints/api/v1/raid_blueprint.rb index afd190b..f4de032 100644 --- a/app/blueprints/api/v1/raid_blueprint.rb +++ b/app/blueprints/api/v1/raid_blueprint.rb @@ -13,7 +13,7 @@ module Api } end - fields :slug, :level, :element + fields :slug, :level, :element, :enemy_id, :summon_id, :quest_id association :group, blueprint: RaidGroupBlueprint, view: :flat end diff --git a/app/controllers/api/v1/raids_controller.rb b/app/controllers/api/v1/raids_controller.rb index 9619d7c..767152a 100644 --- a/app/controllers/api/v1/raids_controller.rb +++ b/app/controllers/api/v1/raids_controller.rb @@ -3,8 +3,8 @@ module Api module V1 class RaidsController < Api::V1::ApiController - before_action :set_raid, only: %i[show update destroy] - before_action :ensure_editor_role, only: %i[create update destroy] + before_action :set_raid, only: %i[show update destroy download_image download_images download_status] + before_action :ensure_editor_role, only: %i[create update destroy download_image download_images] # GET /raids def index @@ -57,6 +57,86 @@ module Api end end + # POST /raids/:id/download_image + # Synchronously downloads a single image for a raid + def download_image + size = params[:size] + force = params[:force] == true + + valid_sizes = Granblue::Downloaders::RaidDownloader::SIZES + unless valid_sizes.include?(size) + return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity + end + + # Check if the required ID exists + if size == 'icon' && @raid.enemy_id.blank? + return render json: { error: 'Raid has no enemy_id configured' }, status: :unprocessable_entity + end + if size == 'thumbnail' && @raid.summon_id.blank? + return render json: { error: 'Raid has no summon_id configured' }, status: :unprocessable_entity + end + if %w[lobby background].include?(size) && @raid.quest_id.blank? + return render json: { error: 'Raid has no quest_id configured' }, status: :unprocessable_entity + end + + begin + downloader = Granblue::Downloaders::RaidDownloader.new( + @raid, + storage: :s3, + force: force, + verbose: true + ) + + downloader.download(size) + + render json: { + success: true, + raid_id: @raid.id, + slug: @raid.slug, + size: size, + message: 'Image downloaded successfully' + } + rescue StandardError => e + Rails.logger.error "[RAIDS] Image download error for #{@raid.id}: #{e.message}" + render json: { success: false, error: e.message }, status: :internal_server_error + end + end + + # POST /raids/:id/download_images + # Triggers async image download for a raid + def download_images + DownloadRaidImagesJob.perform_later( + @raid.id, + force: params.dig(:options, :force) == true, + size: params.dig(:options, :size) || 'all' + ) + + DownloadRaidImagesJob.update_status( + @raid.id, + 'queued', + progress: 0, + images_downloaded: 0 + ) + + render json: { + status: 'queued', + raid_id: @raid.id, + slug: @raid.slug, + message: 'Image download job has been queued' + }, status: :accepted + end + + # GET /raids/:id/download_status + # Returns the status of an image download job + def download_status + status = DownloadRaidImagesJob.status(@raid.id) + + render json: status.merge( + raid_id: @raid.id, + slug: @raid.slug + ) + end + # GET /raids/groups (legacy endpoint) def groups render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).ordered, view: :full) @@ -74,7 +154,7 @@ module Api end def raid_params - params.require(:raid).permit(:name_en, :name_jp, :level, :element, :slug, :group_id) + params.require(:raid).permit(:name_en, :name_jp, :level, :element, :slug, :group_id, :enemy_id, :summon_id, :quest_id) end def apply_filters(scope) diff --git a/app/jobs/download_raid_images_job.rb b/app/jobs/download_raid_images_job.rb new file mode 100644 index 0000000..a9a4113 --- /dev/null +++ b/app/jobs/download_raid_images_job.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Background job for downloading raid images from Granblue servers to S3. +# Stores progress in Redis for status polling. +# +# @example Enqueue a download job +# job = DownloadRaidImagesJob.perform_later(raid.id) +# # Poll status with: DownloadRaidImagesJob.status(raid.id) +class DownloadRaidImagesJob < ApplicationJob + queue_as :downloads + + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + discard_on ActiveRecord::RecordNotFound do |job, _error| + raid_id = job.arguments.first + Rails.logger.error "[DownloadRaidImages] Raid #{raid_id} not found" + update_status(raid_id, 'failed', error: 'Raid not found') + end + + # Status keys for Redis storage + REDIS_KEY_PREFIX = 'raid_image_download' + STATUS_TTL = 1.hour.to_i + + class << self + # Get the current status of a download job for a raid + # + # @param raid_id [String] UUID of the raid + # @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error + def status(raid_id) + data = redis.get(redis_key(raid_id)) + return { status: 'not_found' } unless data + + JSON.parse(data, symbolize_names: true) + end + + def redis_key(raid_id) + "#{REDIS_KEY_PREFIX}:#{raid_id}" + end + + def redis + @redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')) + end + + def update_status(raid_id, status, **attrs) + data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs) + redis.setex(redis_key(raid_id), STATUS_TTL, data.to_json) + end + end + + def perform(raid_id, force: false, size: 'all') + Rails.logger.info "[DownloadRaidImages] Starting download for raid #{raid_id}" + + raid = Raid.find(raid_id) + update_status(raid_id, 'processing', progress: 0, images_downloaded: 0) + + service = RaidImageDownloadService.new( + raid, + force: force, + size: size, + storage: :s3 + ) + + result = service.download + + if result.success? + Rails.logger.info "[DownloadRaidImages] Completed for raid #{raid_id}" + update_status( + raid_id, + 'completed', + progress: 100, + images_downloaded: result.total, + images_total: result.total, + images: result.images + ) + else + Rails.logger.error "[DownloadRaidImages] Failed for raid #{raid_id}: #{result.error}" + update_status(raid_id, 'failed', error: result.error) + raise StandardError, result.error # Trigger retry + end + end + + private + + def update_status(raid_id, status, **attrs) + self.class.update_status(raid_id, status, **attrs) + end +end diff --git a/app/services/raid_image_download_service.rb b/app/services/raid_image_download_service.rb new file mode 100644 index 0000000..516ab4c --- /dev/null +++ b/app/services/raid_image_download_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Service wrapper for downloading raid images from Granblue servers to S3. +# Uses the RaidDownloader but provides a cleaner interface for controllers. +# +# @example Download images for a raid +# service = RaidImageDownloadService.new(raid) +# result = service.download +# if result.success? +# puts result.images +# else +# puts result.error +# end +class RaidImageDownloadService + Result = Struct.new(:success?, :images, :error, :total, keyword_init: true) + + def initialize(raid, options = {}) + @raid = raid + @force = options[:force] || false + @size = options[:size] || 'all' + @storage = options[:storage] || :s3 + end + + # Downloads images for the raid + # + # @return [Result] Struct with success status, images manifest, and any errors + def download + downloader = Granblue::Downloaders::RaidDownloader.new( + @raid, + storage: @storage, + force: @force, + verbose: Rails.env.development? + ) + + selected_size = @size == 'all' ? nil : @size + downloader.download(selected_size) + + manifest = build_image_manifest + + Result.new( + success?: true, + images: manifest, + total: count_total_images(manifest) + ) + rescue StandardError => e + Rails.logger.error "[RaidImageDownload] Failed for #{@raid.slug}: #{e.message}" + Result.new( + success?: false, + error: e.message + ) + end + + private + + def build_image_manifest + manifest = {} + + if @raid.enemy_id + manifest['icon'] = ["#{@raid.enemy_id}.png"] + end + + if @raid.summon_id + manifest['thumbnail'] = ["#{@raid.summon_id}_high.png"] + end + + if @raid.quest_id + manifest['lobby'] = ["#{@raid.quest_id}1.png"] + manifest['background'] = ["#{@raid.quest_id}_raid_image_new.png"] + end + + manifest + end + + def count_total_images(manifest) + manifest.values.sum(&:size) + end +end diff --git a/config/routes.rb b/config/routes.rb index 189b5cb..651bd1a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,7 +113,13 @@ Rails.application.routes.draw do # Raids and RaidGroups resources :raid_groups, only: %i[index show create update destroy] - resources :raids, only: %i[index show create update destroy] + resources :raids, only: %i[index show create update destroy] do + member do + post :download_image + post :download_images + get :download_status + end + end get 'raids/groups', to: 'raids#groups' # Legacy endpoint get 'weapon_keys', to: 'weapon_keys#all' diff --git a/db/migrate/20260106102045_add_game_id_to_raids.rb b/db/migrate/20260106102045_add_game_id_to_raids.rb new file mode 100644 index 0000000..fc48b69 --- /dev/null +++ b/db/migrate/20260106102045_add_game_id_to_raids.rb @@ -0,0 +1,5 @@ +class AddGameIdToRaids < ActiveRecord::Migration[8.0] + def change + add_column :raids, :game_id, :integer + end +end diff --git a/db/migrate/20260106104051_rename_game_id_to_enemy_id_on_raids.rb b/db/migrate/20260106104051_rename_game_id_to_enemy_id_on_raids.rb new file mode 100644 index 0000000..d653e0a --- /dev/null +++ b/db/migrate/20260106104051_rename_game_id_to_enemy_id_on_raids.rb @@ -0,0 +1,5 @@ +class RenameGameIdToEnemyIdOnRaids < ActiveRecord::Migration[8.0] + def change + rename_column :raids, :game_id, :enemy_id + end +end diff --git a/db/migrate/20260106104115_add_summon_id_to_raids.rb b/db/migrate/20260106104115_add_summon_id_to_raids.rb new file mode 100644 index 0000000..0b4e6be --- /dev/null +++ b/db/migrate/20260106104115_add_summon_id_to_raids.rb @@ -0,0 +1,5 @@ +class AddSummonIdToRaids < ActiveRecord::Migration[8.0] + def change + add_column :raids, :summon_id, :bigint + end +end diff --git a/db/migrate/20260106114730_add_quest_id_to_raids.rb b/db/migrate/20260106114730_add_quest_id_to_raids.rb new file mode 100644 index 0000000..6b0e7fd --- /dev/null +++ b/db/migrate/20260106114730_add_quest_id_to_raids.rb @@ -0,0 +1,5 @@ +class AddQuestIdToRaids < ActiveRecord::Migration[8.0] + def change + add_column :raids, :quest_id, :bigint + end +end diff --git a/db/schema.rb b/db/schema.rb index 7bcee92..aad690c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_05_053753) do +ActiveRecord::Schema[8.0].define(version: 2026_01_06_114730) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -755,6 +755,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_05_053753) do t.integer "element" t.string "slug" t.uuid "group_id" + t.integer "enemy_id" + t.bigint "summon_id" + t.bigint "quest_id" end create_table "skill_effects", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/lib/granblue/downloaders/raid_downloader.rb b/lib/granblue/downloaders/raid_downloader.rb new file mode 100644 index 0000000..c847c77 --- /dev/null +++ b/lib/granblue/downloaders/raid_downloader.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +module Granblue + module Downloaders + # Downloads raid image assets from the game server. + # Raids have four different image types from different sources: + # - Icon: from enemy directory using enemy_id + # - Thumbnail: from summon directory using summon_id + # - Lobby: from quest/lobby directory using quest_id (with "1" appended) + # - Background: from quest/treasureraid directory using quest_id + # + # @example Download images for a specific raid + # downloader = RaidDownloader.new(raid, storage: :both) + # downloader.download + # + # @note Unlike other downloaders, RaidDownloader takes a Raid model instance + # since it needs enemy_id, summon_id, and quest_id + class RaidDownloader < BaseDownloader + SIZES = %w[icon thumbnail lobby background].freeze + + ICON_BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/enemy' + THUMBNAIL_BASE_URL = 'https://prd-game-a1-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon' + LOBBY_BASE_URL = 'https://prd-game-a1-granbluefantasy.akamaized.net/assets_en/img/sp/quest/assets/lobby' + BACKGROUND_BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/quest/assets/treasureraid' + + # Initialize with a Raid model instead of just an ID + # @param raid [Raid] Raid model instance + # @param test_mode [Boolean] When true, only logs actions without downloading + # @param verbose [Boolean] When true, enables detailed logging + # @param storage [Symbol] Storage mode (:local, :s3, or :both) + # @param force [Boolean] When true, re-downloads even if file exists + def initialize(raid, test_mode: false, verbose: false, storage: :both, force: false, logger: nil) + @raid = raid + @id = raid.slug # Use slug for logging + @test_mode = test_mode + @verbose = verbose + @storage = storage + @force = force + @logger = logger || Logger.new($stdout) + @aws_service = self.class.aws_service if store_in_s3? + ensure_directories_exist unless @test_mode + end + + # Download images for all available sizes + # @param selected_size [String] The size to download ('icon', 'thumbnail', or nil for all) + # @return [void] + def download(selected_size = nil) + log_info("-> #{@raid.slug}") + return if @test_mode + + sizes = selected_size ? [selected_size] : SIZES + + sizes.each_with_index do |size, index| + case size + when 'icon' + download_icon(last: index == sizes.size - 1) + when 'thumbnail' + download_thumbnail(last: index == sizes.size - 1) + when 'lobby' + download_lobby(last: index == sizes.size - 1) + when 'background' + download_background(last: index == sizes.size - 1) + end + end + end + + private + + # Download the icon image (from enemy directory) + def download_icon(last: false) + return unless @raid.enemy_id + + path = download_path('icon') + url = build_icon_url + filename = "#{@raid.enemy_id}.png" + s3_key = build_s3_key('icon', filename) + download_uri = "#{path}/#{filename}" + + return unless should_download?(download_uri, s3_key) + + log_download('icon', url, last: last) + process_image_download(url, download_uri, s3_key) + rescue OpenURI::HTTPError + log_info "\t404 returned\t#{url}" + end + + # Download the thumbnail image (from summon directory) + def download_thumbnail(last: false) + return unless @raid.summon_id + + path = download_path('thumbnail') + url = build_thumbnail_url + filename = "#{@raid.summon_id}_high.png" + s3_key = build_s3_key('thumbnail', filename) + download_uri = "#{path}/#{filename}" + + return unless should_download?(download_uri, s3_key) + + log_download('thumbnail', url, last: last) + process_image_download(url, download_uri, s3_key) + rescue OpenURI::HTTPError + log_info "\t404 returned\t#{url}" + end + + # Download the lobby image (from quest/lobby directory) + def download_lobby(last: false) + return unless @raid.quest_id + + path = download_path('lobby') + url = build_lobby_url + filename = "#{@raid.quest_id}1.png" + s3_key = build_s3_key('lobby', filename) + download_uri = "#{path}/#{filename}" + + return unless should_download?(download_uri, s3_key) + + log_download('lobby', url, last: last) + process_image_download(url, download_uri, s3_key) + rescue OpenURI::HTTPError + log_info "\t404 returned\t#{url}" + end + + # Download the background image (from quest/treasureraid directory) + def download_background(last: false) + return unless @raid.quest_id + + path = download_path('background') + url = build_background_url + filename = "raid_image_new.png" + s3_key = build_s3_key('background', "#{@raid.quest_id}_raid_image_new.png") + download_uri = "#{path}/#{@raid.quest_id}_#{filename}" + + return unless should_download?(download_uri, s3_key) + + log_download('background', url, last: last) + process_image_download(url, download_uri, s3_key) + rescue OpenURI::HTTPError + log_info "\t404 returned\t#{url}" + end + + def log_download(size, url, last: false) + if last + log_info "\t└ #{size}: #{url}..." + else + log_info "\t├ #{size}: #{url}..." + end + end + + def process_image_download(url, download_uri, s3_key) + case @storage + when :local + download_to_local(url, download_uri) + when :s3 + stream_to_s3(url, s3_key) + when :both + download_to_both(url, download_uri, s3_key) + end + end + + def build_icon_url + "#{ICON_BASE_URL}/m/#{@raid.enemy_id}.png" + end + + def build_thumbnail_url + "#{THUMBNAIL_BASE_URL}/qm/#{@raid.summon_id}_high.png" + end + + def build_lobby_url + "#{LOBBY_BASE_URL}/#{@raid.quest_id}1.png" + end + + def build_background_url + "#{BACKGROUND_BASE_URL}/#{@raid.quest_id}/raid_image_new.png" + end + + def object_type + 'raid' + end + + # Not used for raids since we have custom URLs + def base_url + nil + end + + # Not used for raids since we have custom URL building + def directory_for_size(_size) + nil + end + end + end +end