defmodule SimpleshopTheme.Images.VariantCache do @moduledoc """ Ensures all image variants exist on startup. This GenServer runs at startup and checks for: 1. Database images with incomplete variants_status or missing disk files 2. Mockup source files missing their generated variants For any images missing variants, it enqueues Oban jobs to regenerate them. """ use GenServer require Logger alias SimpleshopTheme.Repo alias SimpleshopTheme.Media.Image, as: ImageSchema alias SimpleshopTheme.Images.{Optimizer, OptimizeWorker} alias SimpleshopTheme.Products alias SimpleshopTheme.Sync.ImageDownloadWorker import Ecto.Query defp mockup_dir, do: Application.app_dir(:simpleshop_theme, "priv/static/mockups") def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @impl true def init(_opts) do Task.start(fn -> ensure_all_variants() end) {:ok, %{}} end defp ensure_all_variants do Logger.info("[VariantCache] Checking image variant cache...") File.mkdir_p!(Optimizer.cache_dir()) reset_stale_sync_status() ensure_database_image_variants() ensure_mockup_variants() ensure_product_image_downloads() end # Reset any provider connections stuck in "syncing" status from interrupted syncs defp reset_stale_sync_status do {count, _} = Products.reset_stale_sync_status() if count > 0 do Logger.info("[VariantCache] Reset #{count} stale sync status(es) to idle") end end defp ensure_database_image_variants do # Only load IDs and source_width for the disk check — avoids loading BLOBs incomplete_ids = ImageSchema |> where([i], i.variants_status != "complete" or is_nil(i.variants_status)) |> where([i], i.is_svg == false) |> select([i], {i.id, i.source_width}) |> Repo.all() complete_missing_ids = ImageSchema |> where([i], i.variants_status == "complete") |> where([i], i.is_svg == false) |> where([i], not is_nil(i.source_width)) |> select([i], {i.id, i.source_width}) |> Repo.all() |> Enum.reject(fn {id, source_width} -> Optimizer.disk_variants_exist?(id, source_width) end) to_process = incomplete_ids ++ complete_missing_ids if to_process == [] do Logger.info("[VariantCache] All database image variants up to date") else Logger.info( "[VariantCache] Processing #{length(to_process)} images with missing variants..." ) # Process directly instead of round-tripping through Oban — more reliable at startup to_process |> Task.async_stream( fn {id, _source_width} -> case Optimizer.process_for_image(id) do {:ok, _} -> :ok {:error, reason} -> Logger.warning("[VariantCache] Failed to process #{id}: #{inspect(reason)}") end end, max_concurrency: 4, timeout: :timer.seconds(30), on_timeout: :kill_task ) |> Enum.count(fn {:ok, :ok} -> true _ -> false end) |> then(fn count -> Logger.info("[VariantCache] Processed #{count}/#{length(to_process)} image variants") end) end end defp ensure_mockup_variants do if File.dir?(mockup_dir()) do sources = Path.wildcard(Path.join(mockup_dir(), "*.webp")) |> Enum.reject(&is_variant?/1) missing = Enum.reject(sources, &mockup_variants_exist?/1) if missing == [] do Logger.info("[VariantCache] All mockup variants up to date") else Logger.info("[VariantCache] Enqueueing #{length(missing)} mockups for processing") Enum.each(missing, &OptimizeWorker.enqueue_mockup/1) end end end defp is_variant?(path) do basename = Path.basename(path) |> Path.rootname() String.match?(basename, ~r/-(400|800|1200|thumb)$/) end defp mockup_variants_exist?(source_path) do basename = Path.basename(source_path) |> Path.rootname() dir = Path.dirname(source_path) expected = ["#{basename}-thumb.jpg"] ++ for w <- [400, 800, 1200], ext <- ["avif", "webp", "jpg"], do: "#{basename}-#{w}.#{ext}" Enum.all?(expected, &File.exists?(Path.join(dir, &1))) end defp ensure_product_image_downloads do pending = Products.list_pending_downloads(limit: 500) if pending == [] do Logger.info("[VariantCache] All product images downloaded") else Logger.info("[VariantCache] Enqueueing #{length(pending)} product images for download") Enum.each(pending, fn image -> ImageDownloadWorker.enqueue(image.id) end) end end end