defmodule Berrypod.Media do @moduledoc """ The Media context for managing images and file uploads. """ import Ecto.Query, warn: false alias Berrypod.Repo alias Berrypod.Media.Image, as: ImageSchema alias Berrypod.Media.FaviconVariant alias Berrypod.Images.Optimizer alias Berrypod.Images.OptimizeWorker @doc """ Uploads an image and stores it in the database. For non-SVG images: - Converts to lossless WebP for storage (26-41% smaller than PNG) - Extracts source dimensions for responsive variant generation - Enqueues background job to generate optimized variants (AVIF, WebP, JPEG at multiple sizes) ## Examples iex> upload_image(%{image_type: "logo", filename: "logo.png", ...}) {:ok, %Image{}} """ def upload_image(attrs) do attrs = prepare_image_attrs(attrs) case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do {:ok, image} -> # Enqueue background job for non-SVG images unless image.is_svg do OptimizeWorker.enqueue(image.id) end {:ok, image} error -> error end end # Prepares image attributes, converting to lossless WebP and extracting dimensions defp prepare_image_attrs(%{data: data, content_type: content_type} = attrs) when is_binary(data) do if is_svg?(content_type, attrs[:filename]) do attrs else case Optimizer.to_optimized_webp(data) do {:ok, webp_data, width, height} -> attrs |> Map.put(:data, webp_data) |> Map.put(:content_type, "image/webp") |> Map.put(:file_size, byte_size(webp_data)) |> Map.put(:source_width, width) |> Map.put(:source_height, height) |> Map.put(:variants_status, "pending") {:error, _reason} -> # If conversion fails, store original image attrs end end end defp prepare_image_attrs(attrs), do: attrs defp is_svg?(content_type, filename) do content_type == "image/svg+xml" or String.ends_with?(filename || "", ".svg") end @doc """ Uploads an image from a LiveView upload entry. This handles consuming the upload and extracting metadata from the entry. ## Examples iex> upload_from_entry(socket, :logo_upload, fn path, entry -> ... end) {:ok, %Image{}} """ def upload_from_entry(path, entry, image_type, extra_attrs \\ %{}) do file_binary = File.read!(path) base = %{ image_type: image_type, filename: entry.client_name, content_type: entry.client_type, file_size: entry.client_size, data: file_binary } upload_image(Map.merge(base, extra_attrs)) end @doc """ Gets a single image by ID. ## Examples iex> get_image(id) %Image{} iex> get_image("nonexistent") nil """ def get_image(id) do Repo.get(ImageSchema, id) end @doc """ Gets the current logo image. ## Examples iex> get_logo() %Image{} """ def get_logo do Repo.one( from i in ImageSchema, where: i.image_type == "logo", order_by: [desc: i.inserted_at], limit: 1 ) end @doc """ Gets the current header image. ## Examples iex> get_header() %Image{} """ def get_header do Repo.one( from i in ImageSchema, where: i.image_type == "header", order_by: [desc: i.inserted_at], limit: 1 ) end @doc """ Deletes an image. ## Examples iex> delete_image(image) {:ok, %Image{}} """ def delete_image(%ImageSchema{} = image) do Repo.delete(image) end @doc """ Lists all images of a specific type. ## Examples iex> list_images_by_type("logo") [%Image{}, ...] """ def list_images_by_type(type) do Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at]) end @doc """ Gets the current icon image (used as favicon source when not using the logo). """ def get_icon do Repo.one( from i in ImageSchema, where: i.image_type == "icon", order_by: [desc: i.inserted_at], limit: 1 ) end # ── Media library functions ────────────────────────────────────── @doc """ Lists images with optional filters. Excludes BLOB data from the query for performance — returned images have `data: nil`. ## Options * `:type` — filter by image_type (e.g. "media", "product") * `:search` — search filename and alt text (case-insensitive) * `:tag` — filter by tag substring in comma-separated tags field """ def list_images(opts \\ []) do query = from(i in ImageSchema, select: %{i | data: nil}, order_by: [desc: i.inserted_at] ) query |> filter_by_type(opts[:type]) |> filter_by_search(opts[:search]) |> filter_by_tag(opts[:tag]) |> Repo.all() end defp filter_by_type(query, nil), do: query defp filter_by_type(query, ""), do: query defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type) defp filter_by_search(query, nil), do: query defp filter_by_search(query, ""), do: query defp filter_by_search(query, term) do pattern = "%#{term}%" where(query, [i], like(i.filename, ^pattern) or like(i.alt, ^pattern)) end defp filter_by_tag(query, nil), do: query defp filter_by_tag(query, ""), do: query defp filter_by_tag(query, tag) do pattern = "%#{tag}%" where(query, [i], like(i.tags, ^pattern)) end @doc "Updates alt text, caption, and tags on an existing image." def update_image_metadata(%ImageSchema{} = image, attrs) do image |> ImageSchema.metadata_changeset(attrs) |> Repo.update() end @doc """ Returns a list of places an image is referenced. Each usage is a map: `%{type: atom, label: String.t()}`. Scans product_images, theme settings, favicon variants, and page blocks. """ def find_usages(image_id) when is_binary(image_id) do find_product_usages(image_id) ++ find_theme_usages(image_id) ++ find_favicon_usages(image_id) ++ find_page_usages(image_id) end @doc """ Returns a MapSet of all image IDs that are referenced somewhere. Used for orphan detection without per-image queries. """ def used_image_ids do ids = MapSet.new() # Product images product_ids = from(pi in Berrypod.Products.ProductImage, where: not is_nil(pi.image_id), select: pi.image_id ) |> Repo.all() ids = Enum.reduce(product_ids, ids, &MapSet.put(&2, &1)) # Theme settings theme = Berrypod.Settings.get_theme_settings() ids = [:logo_image_id, :header_image_id, :icon_image_id] |> Enum.reduce(ids, fn field, acc -> case Map.get(theme, field) do nil -> acc id -> MapSet.put(acc, id) end end) # Favicon variants ids = case get_favicon_variants() do %{source_image_id: id} when not is_nil(id) -> MapSet.put(ids, id) _ -> ids end # Page block settings page_ids = scan_pages_for_image_ids() Enum.reduce(page_ids, ids, &MapSet.put(&2, &1)) end @doc """ Deletes an image and its disk variants. Returns `{:error, :in_use, usages}` if the image is still referenced. """ def delete_with_cleanup(%ImageSchema{} = image) do usages = find_usages(image.id) if usages != [] do {:error, :in_use, usages} else cleanup_disk_variants(image.id) Repo.delete(image) end end # ── Usage scanning helpers ───────────────────────────────────── defp find_product_usages(image_id) do from(pi in Berrypod.Products.ProductImage, join: p in Berrypod.Products.Product, on: p.id == pi.product_id, where: pi.image_id == ^image_id, select: p.title ) |> Repo.all() |> Enum.map(&%{type: :product, label: &1}) end defp find_theme_usages(image_id) do theme = Berrypod.Settings.get_theme_settings() [ {:logo_image_id, "Logo"}, {:header_image_id, "Header background"}, {:icon_image_id, "Site icon"} ] |> Enum.filter(fn {field, _} -> Map.get(theme, field) == image_id end) |> Enum.map(fn {_, label} -> %{type: :theme, label: label} end) end defp find_favicon_usages(image_id) do case get_favicon_variants() do %{source_image_id: ^image_id} -> [%{type: :favicon, label: "Favicon source"}] _ -> [] end end defp find_page_usages(image_id) do Berrypod.Pages.list_all_pages() |> Enum.flat_map(fn page -> page.blocks |> Enum.filter(fn block -> settings = block["settings"] || %{} settings["image_id"] == image_id end) |> Enum.map(fn block -> %{type: :page, label: "#{page.title} — #{block["type"] || "block"}"} end) end) end defp scan_pages_for_image_ids do Berrypod.Pages.list_all_pages() |> Enum.flat_map(fn page -> Enum.flat_map(page.blocks, fn block -> case get_in(block, ["settings", "image_id"]) do nil -> [] id -> [id] end end) end) end defp cleanup_disk_variants(image_id) do cache_dir = Optimizer.cache_dir() if File.exists?(cache_dir) do # Delete all files matching this image ID cache_dir |> File.ls!() |> Enum.filter(&String.starts_with?(&1, image_id)) |> Enum.each(&File.rm(Path.join(cache_dir, &1))) end end # ── Favicon functions ────────────────────────────────────────── @doc """ Gets the current favicon variants (single row). """ def get_favicon_variants do Repo.one( from fv in FaviconVariant, order_by: [desc: fv.generated_at], limit: 1 ) end @doc """ Stores favicon variants, replacing any existing set. """ def store_favicon_variants(attrs) do Repo.delete_all(FaviconVariant) %FaviconVariant{} |> FaviconVariant.changeset( Map.put(attrs, :generated_at, DateTime.utc_now() |> DateTime.truncate(:second)) ) |> Repo.insert() end end