From 70e6d50371411faf46b1cc683ad78faeb54c1ab0 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 13:37:10 -0800 Subject: [PATCH] add artifact image download service and job --- app/jobs/download_artifact_images_job.rb | 87 +++++++++++++++++++ .../artifact_image_download_service.rb | 66 ++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 app/jobs/download_artifact_images_job.rb create mode 100644 app/services/artifact_image_download_service.rb diff --git a/app/jobs/download_artifact_images_job.rb b/app/jobs/download_artifact_images_job.rb new file mode 100644 index 0000000..31f0c0c --- /dev/null +++ b/app/jobs/download_artifact_images_job.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Background job for downloading artifact images from Granblue servers to S3. +# Stores progress in Redis for status polling. +# +# @example Enqueue a download job +# job = DownloadArtifactImagesJob.perform_later(artifact.id) +# # Poll status with: DownloadArtifactImagesJob.status(artifact.id) +class DownloadArtifactImagesJob < ApplicationJob + queue_as :downloads + + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + discard_on ActiveRecord::RecordNotFound do |job, _error| + artifact_id = job.arguments.first + Rails.logger.error "[DownloadArtifactImages] Artifact #{artifact_id} not found" + update_status(artifact_id, 'failed', error: 'Artifact not found') + end + + # Status keys for Redis storage + REDIS_KEY_PREFIX = 'artifact_image_download' + STATUS_TTL = 1.hour.to_i + + class << self + # Get the current status of a download job for an artifact + # + # @param artifact_id [String] UUID of the artifact + # @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error + def status(artifact_id) + data = redis.get(redis_key(artifact_id)) + return { status: 'not_found' } unless data + + JSON.parse(data, symbolize_names: true) + end + + def redis_key(artifact_id) + "#{REDIS_KEY_PREFIX}:#{artifact_id}" + end + + def redis + @redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')) + end + + def update_status(artifact_id, status, **attrs) + data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs) + redis.setex(redis_key(artifact_id), STATUS_TTL, data.to_json) + end + end + + def perform(artifact_id, force: false, size: 'all') + Rails.logger.info "[DownloadArtifactImages] Starting download for artifact #{artifact_id}" + + artifact = Artifact.find(artifact_id) + update_status(artifact_id, 'processing', progress: 0, images_downloaded: 0) + + service = ArtifactImageDownloadService.new( + artifact, + force: force, + size: size, + storage: :s3 + ) + + result = service.download + + if result.success? + Rails.logger.info "[DownloadArtifactImages] Completed for artifact #{artifact_id}" + update_status( + artifact_id, + 'completed', + progress: 100, + images_downloaded: result.total, + images_total: result.total, + images: result.images + ) + else + Rails.logger.error "[DownloadArtifactImages] Failed for artifact #{artifact_id}: #{result.error}" + update_status(artifact_id, 'failed', error: result.error) + raise StandardError, result.error # Trigger retry + end + end + + private + + def update_status(artifact_id, status, **attrs) + self.class.update_status(artifact_id, status, **attrs) + end +end diff --git a/app/services/artifact_image_download_service.rb b/app/services/artifact_image_download_service.rb new file mode 100644 index 0000000..6a2c160 --- /dev/null +++ b/app/services/artifact_image_download_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Service wrapper for downloading artifact images from Granblue servers to S3. +# Uses the existing ArtifactDownloader but provides a cleaner interface for controllers. +# +# @example Download images for an artifact +# service = ArtifactImageDownloadService.new(artifact) +# result = service.download +# if result.success? +# puts result.images +# else +# puts result.error +# end +class ArtifactImageDownloadService + Result = Struct.new(:success?, :images, :error, :total, keyword_init: true) + + def initialize(artifact, options = {}) + @artifact = artifact + @force = options[:force] || false + @size = options[:size] || 'all' + @storage = options[:storage] || :s3 + end + + # Downloads images for the artifact + # + # @return [Result] Struct with success status, images manifest, and any errors + def download + downloader = Granblue::Downloaders::ArtifactDownloader.new( + @artifact.granblue_id, + 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 "[ArtifactImageDownload] Failed for #{@artifact.granblue_id}: #{e.message}" + Result.new( + success?: false, + error: e.message + ) + end + + private + + def build_image_manifest + sizes = Granblue::Downloaders::ArtifactDownloader::SIZES + + sizes.each_with_object({}) do |size, manifest| + manifest[size] = ["#{@artifact.granblue_id}.jpg"] + end + end + + def count_total_images(manifest) + manifest.values.sum(&:size) + end +end