Class: Tempest::AvatarStore
- Inherits:
-
Object
- Object
- Tempest::AvatarStore
- Defined in:
- lib/tempest/avatar_store.rb
Overview
Resolves Bluesky DIDs to a local PNG file path for the actor’s avatar. Mirrors the shape of HandleResolver: an injected client speaks XRPC for ‘app.bsky.actor.getProfile`, and the result is cached in-process so the PDS isn’t hit on every event.
Disk layout: avatars live under ‘cache_dir/` as “<sanitized-did>__<avatar-cid>.png”. The avatar CID is read from the tail of the avatar URL so that re-uploaded avatars (which receive a new CID) invalidate the cache without server-side coordination.
Defined Under Namespace
Classes: DefaultProfileClient
Constant Summary collapse
- NOT_FOUND =
Sentinel for “we tried, there is no avatar” — distinct from “we haven’t looked yet” (nil). Mirrors the pattern in HandleResolver.
Object.new.freeze
- EXT_BY_MIME =
{ "image/jpeg" => "jpg", "image/jpg" => "jpg", "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", "image/avif" => "avif", }.freeze
Class Method Summary collapse
-
.default_converter ⇒ Object
Production format normalizer: shells out to ImageMagick to crop-fit the avatar into a 128x128 PNG.
-
.default_fetcher ⇒ Object
Production HTTP fetcher used when no fetcher is injected.
- .ext_for(content_type, bytes) ⇒ Object
Instance Method Summary collapse
-
#initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil) ⇒ AvatarStore
constructor
A new instance of AvatarStore.
- #path_for(did) ⇒ Object
- #seed(did, path) ⇒ Object
Constructor Details
#initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil) ⇒ AvatarStore
Returns a new instance of AvatarStore.
50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/tempest/avatar_store.rb', line 50 def initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil) @client = client @cache_dir = cache_dir @fetcher = fetcher || self.class.default_fetcher @converter = converter || self.class.default_converter @async = async @executor = executor || method(:default_executor) @cache = {} @pending = {} @mutex = Mutex.new FileUtils.mkdir_p(@cache_dir) end |
Class Method Details
.default_converter ⇒ Object
Production format normalizer: shells out to ImageMagick to crop-fit the avatar into a 128x128 PNG. The crop pads non-square inputs so the Kitty graphics protocol can render at a consistent 1-row, 2-col aspect.
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/tempest/avatar_store.rb', line 82 def self.default_converter @default_converter ||= lambda do |bytes, content_type:| ext = ext_for(content_type, bytes) Dir.mktmpdir do |dir| src = File.join(dir, "src.#{ext}") dst = File.join(dir, "out.png") File.binwrite(src, bytes) _out, status = Open3.capture2e( "magick", src, "-resize", "128x128^", "-gravity", "center", "-extent", "128x128", dst, ) raise "magick convert failed" unless status.success? File.binread(dst) end end end |
.default_fetcher ⇒ Object
Production HTTP fetcher used when no fetcher is injected. Returns the raw bytes and Content-Type header so the converter can pick the right input format.
66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/tempest/avatar_store.rb', line 66 def self.default_fetcher @default_fetcher ||= lambda do |url| uri = URI(url) res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| http.get(uri.request_uri) end unless res.is_a?(Net::HTTPSuccess) raise Tempest::APIError.new(res.code.to_i, { "error" => res. }) end [res.body, res["content-type"].to_s] end end |
.ext_for(content_type, bytes) ⇒ Object
111 112 113 114 115 116 117 118 119 120 |
# File 'lib/tempest/avatar_store.rb', line 111 def self.ext_for(content_type, bytes) mime = content_type.to_s.split(";").first.to_s.strip.downcase return EXT_BY_MIME[mime] if EXT_BY_MIME.key?(mime) head = bytes.byteslice(0, 16).to_s return "jpg" if head.start_with?("\xFF\xD8\xFF".b) return "png" if head.start_with?("\x89PNG\r\n\x1A\n".b) return "gif" if head.start_with?("GIF87a", "GIF89a") return "webp" if head[0, 4] == "RIFF" && head[8, 4] == "WEBP" "bin" end |
Instance Method Details
#path_for(did) ⇒ Object
122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/tempest/avatar_store.rb', line 122 def path_for(did) cached = @mutex.synchronize { @cache[did] } return cached_value(cached) unless cached.nil? if @async enqueue_resolve(did) nil else resolve_and_cache(did) end end |
#seed(did, path) ⇒ Object
134 135 136 |
# File 'lib/tempest/avatar_store.rb', line 134 def seed(did, path) @mutex.synchronize { @cache[did] = path } end |