require 'addressable/template' require 'async' require 'async/barrier' require 'async/semaphore' class SwfAsset < ApplicationRecord # We use the `type` column to mean something other than what Rails means! self.inheritance_column = nil # Used in `item_is_body_specific?`. (TODO: Could we refactor this out?) attr_accessor :item belongs_to :zone has_many :parent_swf_asset_relationships has_one :contribution, :as => :contributed, :inverse_of => :contributed before_validation :normalize_manifest_url, if: :manifest_url? delegate :depth, :to => :zone scope :biology_assets, -> { where(:type => PetState::SwfAssetType) } scope :object_assets, -> { where(:type => Item::SwfAssetType) } CANVAS_MOVIE_IMAGE_URL_TEMPLATE = Addressable::Template.new( Rails.configuration.impress_2020_origin + "/api/assetImage{?libraryUrl,size}" ) LEGACY_IMAGE_URL_TEMPLATE = Addressable::Template.new( "https://aws.impress-asset-images.openneo.net/{type}" + "/{id1}/{id2}/{id3}/{id}/{size}x{size}.png?v2-{time}" ) def as_json(options={}) super({ only: [:id, :known_glitches], methods: [:zone, :restricted_zones, :urls] }.merge(options)) end def urls { swf: url, png: image_url, svg: manifest_asset_urls[:svg], canvas_library: manifest_asset_urls[:js], manifest: manifest_url, } end def manifest @manifest ||= load_manifest end def preload_manifest(save_changes: true) load_manifest(return_content: false, save_changes:) end def load_manifest(return_content: true, save_changes: true) return nil if manifest_url.blank? # If we recently tried loading the manifest and got a 4xx HTTP status code # (e.g. a 404, there's a surprising amount of these!), don't try again. But # after enough time passes, if this is called again, we will! # # (We always retry 5xx errors, on the assumption that they probably # represent intermittent failures, whereas 4xx errors are not likely to # succeed just by retrying.) if manifest_loaded_at.present? last_try_was_4xx =(400...500).include?(manifest_status_code) last_try_was_recent = (Time.now - manifest_loaded_at) <= 1.day if last_try_was_4xx and last_try_was_recent Rails.logger.debug "Skipping loading manifest for asset #{id}: " + "last try was status #{manifest_status_code} at #{manifest_loaded_at}" return nil end end # Try loading the manifest. If we fail, record that we failed and return. begin Sync do |task| task.with_timeout(5) do NeopetsMediaArchive.load_file(manifest_url, return_content:) end end => {content:, source:} rescue Async::TimeoutError # If the request times out, record nothing and return nothing! We'll try # again sometime, on the assumption that this is intermittent. Rails.logger.warn("Timed out loading manifest for asset #{id}") return nil rescue NeopetsMediaArchive::ResponseNotOK => error Rails.logger.warn "Failed to load manifest for asset #{id}: " + error.message self.manifest_loaded_at = Time.now self.manifest_status_code = error.status save! if save_changes return nil end # If this was a fresh load over the network (or for some reason we're # missing the timestamp), record that we succeeded. if source == "network" || manifest_loaded_at.blank? self.manifest_loaded_at = Time.now self.manifest_status_code = 200 save! if save_changes end return nil unless return_content # skip parsing if not needed! # Parse the manifest as JSON, and return it! begin JSON.parse(content) rescue JSON::ParserError => error Rails.logger.warn "Failed to parse manifest for asset #{id}: " + error.message return nil end end MANIFEST_BASE_URL = Addressable::URI.parse("https://images.neopets.com") def manifest_asset_urls return {} unless manifest.present? begin # Organize the asset URLs by file extension, convert them from paths to # full URLs, and grab the ones we want. assets_by_ext = manifest["cpmanifest"]["assets"][0]["asset_data"]. group_by { |a| a["file_ext"].to_sym }. transform_values do |assets| assets.map { |a| (MANIFEST_BASE_URL + a["url"]).to_s } end if assets_by_ext[:js].present? # If a JS asset is present, assume any other assets are supporting # assets, and skip them. (e.g. if there's a PNG, it's likely to be an # "atlas" file used in the animation, rather than a thumbnail.) # # NOTE: We take the last one, because sometimes there are multiple JS # assets in the same manifest, and earlier ones are broken and later # ones are fixed. I don't know the logic exactly, but that's what we've # seen! { js: assets_by_ext[:js].last, sprites: assets_by_ext.fetch(:png, []), } else # Otherwise, return the first PNG and the first SVG. (Unlike the JS # case, it's important to choose the *first* PNG, because sometimes # reference art is included in the manifest, like with the Stealthy # Eyrie Shirt's asset 304486_b28cae0d76.) { png: assets_by_ext.fetch(:png, []).first, svg: assets_by_ext.fetch(:svg, []).first, } end rescue StandardError => error Rails.logger.error "Could not read URLs from manifest: #{error.full_message}" return {} end end def image_url # Use the PNG image from the manifest, if one exists. return manifest_asset_urls[:png] if manifest_asset_urls[:png].present? # Or, if this is a canvas movie, let Impress 2020 generate a PNG for us. return canvas_movie_image_url if manifest_asset_urls[:js].present? # Otherwise, if we don't have the manifest or it doesn't have the files we # need, fall back to the Classic DTI image storage, which was generated # from the SWFs via an old version of gnash (or sometimes manually # overridden). It's less accurate, but well-tested to generally work okay, # and it's the only image we have for assets not yet converted to HTML5. # # NOTE: We've stopped generating these images for new assets! This is # mainly for old assets not yet converted to HTML5. # # NOTE: If you're modeling from a fresh development database, `has_image?` # might be false even though we *do* have a saved copy of the image # available in production. But if you're using the public modeling # data exported from production, then this check should be fine! # # TODO: Rename `has_image?` to `has_legacy_image?`. return legacy_image_url if has_image? # Otherwise, there's no image URL. nil end def canvas_movie? canvas_movie_library_url.present? end def canvas_movie_library_url manifest_asset_urls[:js] end def canvas_movie_sprite_urls return [] unless canvas_movie? manifest_asset_urls[:sprites] end def canvas_movie_image_url return nil unless canvas_movie? CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand( libraryUrl: manifest_asset_urls[:js], size: 600, ).to_s end def legacy_image_url return nil unless has_image? padded_id = remote_id.to_s.rjust(12, "0") LEGACY_IMAGE_URL_TEMPLATE.expand( type: type, id1: padded_id[0...3], id2: padded_id[3...6], id3: padded_id[6...9], id: remote_id, size: "600", time: converted_at.to_i, ).to_s end def known_glitches self[:known_glitches].split(',') end def known_glitches=(new_known_glitches) if new_known_glitches.is_a? Array new_known_glitches = new_known_glitches.join(',') end self[:known_glitches] = new_known_glitches end def html5? # NOTE: This is slightly different than how Impress 2020 reasons about # this; it checks for an SVG or canvas movie. I *think* we did # this just to keep the API simpler, and this check is more # correct? But I do wonder if any assets have a manifest but are # arguably "not converted" because the manifest is just so bad. # NOTE: Just checking `manifest_url` isn't enough, because there *are* # assets with a `manifest_url` saved but it 404s. manifest.present? end def restricted_zone_ids [].tap do |ids| zones_restrict.chars.each_with_index do |bit, index| ids << index + 1 if bit == "1" end end end def restricted_zones Zone.where(id: restricted_zone_ids) end def body_specific? self.zone.type_id < 3 || item_is_body_specific? end def item_is_body_specific? # Get items that we're already bound to in the database, and # also the one passed to us from the current modeling operation, # if any. # # NOTE: I know this has perf impact... it would be better for # modeling to preload this probably? But oh well! items = parent_swf_asset_relationships.includes(:parent).where(parent_type: "Item").map { |r| r.parent } items << item if item # Return whether any of them is known to be body-specific. # This ensures that we always respect the explicitly_body_specific flag! return items.any? { |i| i.body_specific? } end def add_compatible_body_id(new_body_id) if !body_specific? # If this asset is already known to not be body-specific (e.g. by its # zone), use 0 as our body ID. self.body_id = 0 elsif body_id.nil? # If this asset has not body ID yet, assume this is it. (It's possible it # might also fit other pet bodies, which we'll discover soon enough!) self.body_id = new_body_id elsif body_id != new_body_id # If this asset already has a body ID, but this one is different, assume # it actually fits all bodies, and use 0. (Note that we're talking about # *asset* compatibility here, not *item* compatibility. Seeing the same # *asset* on two bodies is a pretty sure sign it fits them all!) self.body_id = 0 else # Otherwise, we already have a body ID, and it matches the incoming one. # No change! end end def normalize_manifest_url parsed_manifest_url = Addressable::URI.parse(manifest_url) parsed_manifest_url.scheme = "https" self.manifest_url = parsed_manifest_url.to_s end def self.from_biology_data(body_id, data) type = "biology" remote_id = data[:part_id].to_i SwfAsset.find_or_initialize_by(type:, remote_id:).tap do |swf_asset| swf_asset.assign_attributes( body_id:, zone_id: data[:zone_id].to_i, url: data[:asset_url], manifest_url: data[:manifest], zones_restrict: data[:zones_restrict], ) end end def self.from_object_data(body_id, data) type = "object" remote_id = data[:asset_id].to_i SwfAsset.find_or_initialize_by(type:, remote_id:).tap do |swf_asset| swf_asset.assign_attributes( zone_id: data[:zone_id].to_i, url: data[:asset_url], manifest_url: data[:manifest], zones_restrict: "", ) swf_asset.add_compatible_body_id(body_id) end end # Given a list of SWF assets, ensure all of their manifests are loaded, with # fast concurrent execution! def self.preload_manifests(swf_assets) # Blocks all tasks beneath it. barrier = Async::Barrier.new Sync do # Only allow 10 manifests to be loaded at a time. semaphore = Async::Semaphore.new(10, parent: barrier) # Load all the manifests in async tasks. This will load them 10 at a time # rather than all at once (because of the semaphore), and the # NeopetsMediaArchive will share a pool of persistent connections for # them. swf_assets.map do |swf_asset| semaphore.async do begin # Don't save changes in this big async situation; we'll do it all # in one batch after, to avoid too much database concurrency! swf_asset.preload_manifest(save_changes: false) rescue StandardError => error Rails.logger.error "Could not preload manifest for asset " + "#{swf_asset.id} (#{swf_asset.manifest_url}): #{error.message}" end end end # Wait until all tasks are done. barrier.wait ensure barrier.stop # If something goes wrong, clean up all tasks. end SwfAsset.transaction do swf_assets.select(&:changed?).each(&:save!) end end end