Class: Tempest::AvatarStore

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil) ⇒ AvatarStore

Returns a new instance of AvatarStore.



48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/tempest/avatar_store.rb', line 48

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_converterObject

Production format normalizer: uses libvips (via ruby-vips) to crop-fit the avatar into a 128x128, 8-bit sRGB PNG in-process. The center crop pads non-square inputs so the Kitty graphics protocol can render at a consistent 1-row, 2-col aspect, and the explicit 8-bit pngsave avoids the 16-bit output that some ImageMagick builds emit, which kitty refuses to draw.



83
84
85
86
87
88
89
90
# File 'lib/tempest/avatar_store.rb', line 83

def self.default_converter
  @default_converter ||= lambda do |bytes, content_type:|
    require "vips"
    image = Vips::Image.thumbnail_buffer(bytes, 128, height: 128, crop: :centre)
    image = image.colourspace("srgb") unless image.interpretation == :srgb
    image.pngsave_buffer(bitdepth: 8)
  end
end

.default_fetcherObject

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.



64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/tempest/avatar_store.rb', line 64

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.message })
    end
    [res.body, res["content-type"].to_s]
  end
end

Instance Method Details

#path_for(did) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/tempest/avatar_store.rb', line 92

def path_for(did)
  cached = @mutex.synchronize { @cache[did] }
  return cached_value(cached) unless cached.nil?

  if @async && (path = cached_file_for(did))
    @mutex.synchronize { @cache[did] = path }
    return path
  end

  if @async
    enqueue_resolve(did)
    nil
  else
    resolve_and_cache(did)
  end
end

#seed(did, path) ⇒ Object



109
110
111
# File 'lib/tempest/avatar_store.rb', line 109

def seed(did, path)
  @mutex.synchronize { @cache[did] = path }
end