feat: add product image download pipeline for PageSpeed 100%

Downloads Printify CDN images via ImageDownloadWorker, processes
through Media pipeline (WebP conversion, AVIF/WebP variant generation),
and links to ProductImage via new image_id FK.

- Add image_id to product_images table
- ImageDownloadWorker downloads and processes external images
- sync_product_images preserves image_id when URL unchanged
- PreviewData uses local images for responsive <picture> elements
- VariantCache enqueues pending downloads on startup
- mix simpleshop.download_images backfill task

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-01 00:26:19 +00:00
parent c818d0399c
commit 1b49b470f2
12 changed files with 381 additions and 33 deletions

View File

@@ -0,0 +1,139 @@
defmodule SimpleshopTheme.Sync.ImageDownloadWorker do
@moduledoc """
Oban worker for downloading product images from external URLs.
Downloads images from Printify CDN, processes through the Media pipeline
(WebP conversion, AVIF/WebP variant generation), and links to ProductImage.
## Usage
# Enqueue a download for a product image
ImageDownloadWorker.enqueue(product_image_id)
## Job Args
* `product_image_id` - The ID of the ProductImage to download
"""
use Oban.Worker, queue: :images, max_attempts: 3
alias SimpleshopTheme.Products
alias SimpleshopTheme.Media
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"product_image_id" => product_image_id}}) do
case Products.get_product_image(product_image_id) do
nil ->
{:cancel, :product_image_not_found}
%{image_id: image_id} when not is_nil(image_id) ->
# Already has a linked image, skip
:ok
product_image ->
download_and_link(product_image)
end
end
@doc """
Enqueue an image download for a product image.
"""
def enqueue(product_image_id) do
%{product_image_id: product_image_id}
|> new()
|> Oban.insert()
end
defp download_and_link(product_image) do
case download_image(product_image.src) do
{:ok, data, content_type} ->
upload_and_link(product_image, data, content_type)
{:error, reason} ->
Logger.warning(
"[ImageDownloadWorker] Failed to download #{product_image.src}: #{inspect(reason)}"
)
{:error, reason}
end
end
defp download_image(url) do
case Req.get(url, receive_timeout: 30_000) do
{:ok, %Req.Response{status: 200, body: body, headers: headers}} ->
content_type = get_content_type(headers)
{:ok, body, content_type}
{:ok, %Req.Response{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
end
defp get_content_type(headers) do
headers
|> Enum.find(fn {k, _v} -> String.downcase(k) == "content-type" end)
|> case do
{_, value} when is_binary(value) -> value |> String.split(";") |> hd() |> String.trim()
{_, [value | _]} -> value |> String.split(";") |> hd() |> String.trim()
_ -> "image/jpeg"
end
end
defp upload_and_link(product_image, data, content_type) do
filename = extract_filename(product_image.src, content_type)
attrs = %{
image_type: "product",
filename: filename,
content_type: content_type,
file_size: byte_size(data),
data: data
}
case Media.upload_image(attrs) do
{:ok, image} ->
case Products.link_product_image(product_image, image.id) do
{:ok, _} ->
Logger.info(
"[ImageDownloadWorker] Downloaded and linked image for #{product_image.id}"
)
:ok
{:error, reason} ->
Logger.error("[ImageDownloadWorker] Failed to link image: #{inspect(reason)}")
{:error, reason}
end
{:error, reason} ->
Logger.error("[ImageDownloadWorker] Failed to upload image: #{inspect(reason)}")
{:error, reason}
end
end
defp extract_filename(url, content_type) do
# Extract filename from URL path, fall back to generated name
uri = URI.parse(url)
path_parts = String.split(uri.path || "", "/")
basename = List.last(path_parts) || "image"
# Ensure it has an extension
if Path.extname(basename) == "" do
ext = extension_for_content_type(content_type)
"#{basename}#{ext}"
else
basename
end
end
defp extension_for_content_type("image/jpeg"), do: ".jpg"
defp extension_for_content_type("image/png"), do: ".png"
defp extension_for_content_type("image/webp"), do: ".webp"
defp extension_for_content_type("image/gif"), do: ".gif"
defp extension_for_content_type(_), do: ".jpg"
end

View File

@@ -20,6 +20,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers.Provider
alias SimpleshopTheme.Sync.ImageDownloadWorker
require Logger
@@ -157,7 +158,13 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
}
end)
Products.sync_product_images(product, images)
image_results = Products.sync_product_images(product, images)
# Enqueue downloads for images without image_id
Enum.each(image_results, fn
{:ok, %{image_id: nil, id: id}} -> ImageDownloadWorker.enqueue(id)
_ -> :ok
end)
# Sync variants
variants =