From aa72692eafac0676a87d86263cad9455e69d8f4c Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Wed, 17 Sep 2025 16:18:28 +0300 Subject: [PATCH] Cache transformations --- lib/music_library/application.ex | 4 ++ lib/music_library/assets/cache.ex | 16 ++++++ lib/music_library/records/cover.ex | 21 +++++--- .../controllers/cover_controller.ex | 52 +++++++++++++------ test/music_library/records/cover_test.exs | 1 - .../controllers/cover_controller_test.exs | 2 +- 6 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 lib/music_library/assets/cache.ex diff --git a/lib/music_library/application.ex b/lib/music_library/application.ex index bcb274bc..85aaaf2f 100644 --- a/lib/music_library/application.ex +++ b/lib/music_library/application.ex @@ -5,8 +5,12 @@ defmodule MusicLibrary.Application do use Application + alias MusicLibrary.Assets + @impl true def start(_type, _args) do + _ = Assets.Cache.new() + children = [ MusicLibraryWeb.Telemetry, MusicLibrary.Vault, diff --git a/lib/music_library/assets/cache.ex b/lib/music_library/assets/cache.ex new file mode 100644 index 00000000..91873696 --- /dev/null +++ b/lib/music_library/assets/cache.ex @@ -0,0 +1,16 @@ +defmodule MusicLibrary.Assets.Cache do + def new do + :ets.new(__MODULE__, [:named_table, :public, read_concurrency: true]) + end + + def set(payload, format, content) do + :ets.insert(__MODULE__, {{payload, format}, content}) + end + + def get(payload, format) do + case :ets.lookup(__MODULE__, {payload, format}) do + [{{^payload, ^format}, content}] -> {:found, content} + [] -> :not_found + end + end +end diff --git a/lib/music_library/records/cover.ex b/lib/music_library/records/cover.ex index d6b3098b..6d2bd2bf 100644 --- a/lib/music_library/records/cover.ex +++ b/lib/music_library/records/cover.ex @@ -6,18 +6,25 @@ defmodule MusicLibrary.Records.Cover do @external_resource fallback_path - @size 2000 + @default_size 2000 + @default_format "image/jpeg" def fallback_data, do: unquote(fallback_data) - def resize(cover_data, size \\ @size) do + def resize(cover_data, size \\ @default_size, format \\ @default_format) do {:ok, thumb} = Operation.thumbnail_buffer(cover_data, size) - Image.write_to_buffer(thumb, ".jpg") + Image.write_to_buffer(thumb, extension(format)) end - def correct_size?(cover_data) do - {:ok, image} = Image.new_from_buffer(cover_data) - - Image.width(image) == @size + def convert(cover_data, data_format, target_format) do + if data_format == target_format do + {:ok, cover_data} + else + {:ok, image} = Image.new_from_buffer(cover_data) + Image.write_to_buffer(image, extension(target_format)) + end end + + defp extension("image/jpeg"), do: ".jpg" + defp extension("image/webp"), do: ".webp" end diff --git a/lib/music_library_web/controllers/cover_controller.ex b/lib/music_library_web/controllers/cover_controller.ex index 9161382b..b8c918f0 100644 --- a/lib/music_library_web/controllers/cover_controller.ex +++ b/lib/music_library_web/controllers/cover_controller.ex @@ -2,36 +2,58 @@ defmodule MusicLibraryWeb.CoverController do use MusicLibraryWeb, :controller alias MusicLibrary.Assets - alias MusicLibrary.Assets.Transform + alias MusicLibrary.Assets.{Cache, Transform} alias MusicLibrary.Records.Cover # 1 year in seconds @cache_duration 60 * 60 * 24 * 365 def show(conn, %{"transform_payload" => payload}) do + format = pick_format(conn) transform = Transform.decode!(payload) - case Assets.get(transform.hash) do + case cached_get(payload, transform, format) do nil -> not_found(conn) - %{content: content} -> - image_data = - if transform.width do - # TODO: find a way to cache computation, or pre-compute thumb and store it - {:ok, thumb_data} = Cover.resize(content, transform.width) - thumb_data - else - content - end - + content when is_binary(content) -> case get_req_header(conn, "if-none-match") do [^payload] -> extend_cache(conn) - _ -> respond_with_cache(conn, image_data, payload) + _ -> respond_with_cache(conn, content, format, payload) end end end + defp cached_get(payload, transform, format) do + case Cache.get(payload, format) do + :not_found -> + if asset = Assets.get(transform.hash) do + image_data = + if transform.width do + # TODO: find a way to cache computation, or pre-compute thumb and store it + {:ok, data} = Cover.resize(asset.content, transform.width, format) + data + else + {:ok, data} = Cover.convert(asset.content, asset.format, format) + data + end + + Cache.set(payload, format, image_data) + image_data + end + + {:found, content} -> + content + end + end + + defp pick_format(conn) do + case get_req_header(conn, "accept") do + ["image/webp" <> _] -> "image/webp" + _ -> "image/jpeg" + end + end + defp not_found(conn) do conn |> put_status(:not_found) @@ -44,9 +66,9 @@ defmodule MusicLibraryWeb.CoverController do |> send_resp(304, "") end - defp respond_with_cache(conn, data, etag) do + defp respond_with_cache(conn, data, format, etag) do conn - |> put_resp_content_type("image/jpeg", "utf-8") + |> put_resp_content_type(format, "utf-8") |> put_resp_header("cache-control", "public, max-age=#{@cache_duration}") |> put_resp_header("etag", etag) |> send_resp(200, data) diff --git a/test/music_library/records/cover_test.exs b/test/music_library/records/cover_test.exs index 8d279d53..e7384310 100644 --- a/test/music_library/records/cover_test.exs +++ b/test/music_library/records/cover_test.exs @@ -11,7 +11,6 @@ defmodule MusicLibrary.Records.CoverTest do cover_data = marbles_cover_data() {:ok, resized_cover} = Cover.resize(cover_data) assert cover_data !== resized_cover - assert Cover.correct_size?(resized_cover) end end end diff --git a/test/music_library_web/controllers/cover_controller_test.exs b/test/music_library_web/controllers/cover_controller_test.exs index 5dafdaed..53529fe6 100644 --- a/test/music_library_web/controllers/cover_controller_test.exs +++ b/test/music_library_web/controllers/cover_controller_test.exs @@ -12,7 +12,7 @@ defmodule MusicLibraryWeb.CoverControllerTest do %{asset: asset} end - describe "GET /covers/:hash" do + describe "GET /covers/:payload" do setup [:create_asset] test "404s when asset doesn't exist", %{conn: conn} do