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:
139
lib/simpleshop_theme/sync/image_download_worker.ex
Normal file
139
lib/simpleshop_theme/sync/image_download_worker.ex
Normal 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
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user