From f52b1af0c2a2e285e2c37043ee96ebe27cc7e6be Mon Sep 17 00:00:00 2001 From: bluebird42 <2034043+bluebird42@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:56:39 +0200 Subject: [PATCH] Store cache and inline playback headers on MP4 outputs Public MP4 and fMP4 objects are served straight from object storage or a CDN, so the caching policy has to live on the object itself. Versioned output keys get a one-year immutable lifetime; unversioned legacy keys can be rewritten in place and get 24 hours. Private signed media URLs override the stored policy so a shared cache never outlives the signature. Co-Authored-By: Claude Opus 5.5 --- lib/mave_core/media/storage.ex | 35 +++++++- lib/mave_core/playback/media.ex | 7 +- test/mave_core/media/storage_test.exs | 81 ++++++++++++++++++- .../api/playback_controller_test.exs | 11 ++- 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/lib/mave_core/media/storage.ex b/lib/mave_core/media/storage.ex index 48db20f..d610c0c 100644 --- a/lib/mave_core/media/storage.ex +++ b/lib/mave_core/media/storage.ex @@ -49,6 +49,13 @@ defmodule MaveCore.Media.Storage do @copy_tmp_root "mave-storage-copy" @default_object_acl_profiles ["eu", "eu_3"] @space_hash_pattern ~r/^[a-z0-9]{5}$/ + @cacheable_media_content_types ["video/mp4", "audio/mp4"] + # Versioned output keys (`{embed}/v{n}/...`) are never rewritten with different + # bytes, so browsers and CDNs may keep them without revalidating. Unversioned + # legacy keys can be replaced in place and get a short lifetime instead. + @versioned_media_cache_control "public, max-age=31536000, immutable" + @unversioned_media_cache_control "public, max-age=86400" + @versioned_media_key_pattern ~r{(?:^|/)v[1-9][0-9]*/} @doc """ Returns the bucket name for a given space hash. @@ -392,6 +399,7 @@ defmodule MaveCore.Media.Storage do {"content-type", content_type}, {"content-length", Integer.to_string(size)} ] + |> maybe_add_media_headers(path, content_type, opts) |> maybe_add_public_read_header(storage_profile, opts) put_object_with_retry( @@ -418,6 +426,7 @@ defmodule MaveCore.Media.Storage do headers = [{"content-type", content_type}] |> maybe_add_content_length_header(opts) + |> maybe_add_media_headers(path, content_type, opts) |> maybe_add_public_read_header(storage_profile, opts) put_object_with_retry(bucket, path, storage_profile, headers, fn -> body end) @@ -635,6 +644,7 @@ defmodule MaveCore.Media.Storage do {"content-type", content_type}, {"content-length", Integer.to_string(content_length)} ] + |> maybe_add_media_headers(path, content_type, public: true) |> maybe_add_public_read_header(storage_profile, public: true) with {:ok, endpoint} <- required_storage_config(config, :endpoint), @@ -2164,9 +2174,12 @@ defmodule MaveCore.Media.Storage do end defp initiate_multipart_upload(bucket, path, storage_profile, content_type, opts \\ []) do + public? = Keyword.get(opts, :public, true) + headers = [{"content-type", content_type}] - |> maybe_add_public_read_header(storage_profile, public: Keyword.get(opts, :public, true)) + |> maybe_add_media_headers(path, content_type, public: public?) + |> maybe_add_public_read_header(storage_profile, public: public?) result = build_req(bucket, storage_profile) @@ -3210,6 +3223,26 @@ defmodule MaveCore.Media.Storage do end end + # Public MP4 and fMP4 outputs are served straight from storage or a CDN, so the + # caching and inline-playback headers are stored as object metadata. + defp maybe_add_media_headers(headers, path, content_type, opts) do + if Keyword.get(opts, :public, false) and content_type in @cacheable_media_content_types do + headers ++ + [ + {"cache-control", media_cache_control(path)}, + {"content-disposition", "inline"} + ] + else + headers + end + end + + defp media_cache_control(path) do + if Regex.match?(@versioned_media_key_pattern, path), + do: @versioned_media_cache_control, + else: @unversioned_media_cache_control + end + defp copy_source_header(bucket, path) do encoded_path = path diff --git a/lib/mave_core/playback/media.ex b/lib/mave_core/playback/media.ex index 51b375b..bf62541 100644 --- a/lib/mave_core/playback/media.ex +++ b/lib/mave_core/playback/media.ex @@ -79,9 +79,14 @@ defmodule MaveCore.Playback.Media do ttl = expires_at - System.system_time(:second) bucket = Storage.bucket_for_space(embed.space.hash, embed.space.region) + # Public objects carry long-lived shared caching metadata. A signed URL must + # not outlive its signature in a shared cache. + query = [{"response-cache-control", "private, max-age=#{ttl}"}] + if ttl > 0 do case storage().presigned_get_url(bucket, embed.hash <> "/" <> path, embed.space.region, - expires: ttl + expires: ttl, + query_params: query ) do {:ok, url} -> {:redirect, url} {:error, _reason} -> {:error, :storage_unavailable} diff --git a/test/mave_core/media/storage_test.exs b/test/mave_core/media/storage_test.exs index 4d438b2..2b8ffb0 100644 --- a/test/mave_core/media/storage_test.exs +++ b/test/mave_core/media/storage_test.exs @@ -368,6 +368,8 @@ defmodule MaveCore.Media.StorageTest do assert conn.method == "PUT" assert conn.request_path == "/space-mett/manifest.json" assert get_req_header(conn, "x-amz-acl") == ["public-read"] + assert get_req_header(conn, "cache-control") == [] + assert get_req_header(conn, "content-disposition") == [] send_resp(conn, 200, "") end) @@ -382,6 +384,72 @@ defmodule MaveCore.Media.StorageTest do ) end + test "public MP4 outputs store caching and inline playback headers" do + Req.default_options(plug: {Req.Test, __MODULE__}) + + Application.put_env(:mave_core, :storage_providers, %{ + "mett" => [ + access_key_id: "minioadmin", + secret_access_key: "minioadmin", + endpoint: "http://localhost:9010", + region: "fr-par", + bucket_prefix: "space-" + ] + }) + + Req.Test.expect(__MODULE__, 3, fn conn -> + expected_cache_control = + case conn.request_path do + "/space-mett/embed/v2/" <> _rest -> "public, max-age=31536000, immutable" + "/space-mett/embed/h264_sd.mp4" -> "public, max-age=86400" + end + + assert get_req_header(conn, "cache-control") == [expected_cache_control] + assert get_req_header(conn, "content-disposition") == ["inline"] + + send_resp(conn, 200, "") + end) + + assert {:ok, ""} = + Storage.put_public("space-mett", "embed/v2/h264_sd.mp4", "mp4", "video/mp4", "mett") + + assert {:ok, ""} = + Storage.put_public( + "space-mett", + "embed/v2/audio_hls/segment_000.m4s", + "m4s", + "audio/mp4", + "mett" + ) + + assert {:ok, ""} = + Storage.put_public("space-mett", "embed/h264_sd.mp4", "mp4", "video/mp4", "mett") + end + + test "private MP4 writes do not store public caching headers" do + Req.default_options(plug: {Req.Test, __MODULE__}) + + Application.put_env(:mave_core, :storage_providers, %{ + "mett" => [ + access_key_id: "minioadmin", + secret_access_key: "minioadmin", + endpoint: "http://localhost:9010", + region: "fr-par", + bucket_prefix: "space-" + ] + }) + + Req.Test.expect(__MODULE__, fn conn -> + assert get_req_header(conn, "cache-control") == [] + assert get_req_header(conn, "content-disposition") == [] + + send_resp(conn, 200, "") + end) + + assert {:ok, ""} = + Storage.put("space-mett", "embed/v2/source.mp4", "mp4", "video/mp4", "mett") + end + test "put_public retries transient transport failures" do Req.default_options(plug: {Req.Test, __MODULE__}) @@ -1274,6 +1342,8 @@ defmodule MaveCore.Media.StorageTest do assert conn.query_string == "uploads" assert get_req_header(conn, "content-type") == ["video/mp4"] assert get_req_header(conn, "x-amz-acl") == ["public-read"] + assert get_req_header(conn, "cache-control") == ["public, max-age=86400"] + assert get_req_header(conn, "content-disposition") == ["inline"] send_resp( conn, @@ -1329,6 +1399,7 @@ defmodule MaveCore.Media.StorageTest do Req.Test.expect(__MODULE__, fn conn -> assert conn.method == "POST" assert get_req_header(conn, "x-amz-acl") == [] + assert get_req_header(conn, "cache-control") == [] send_resp( conn, @@ -1362,7 +1433,7 @@ defmodule MaveCore.Media.StorageTest do assert {:ok, %{"url" => url, "headers" => headers}} = Storage.presigned_put_url( "space-qingb", - "embed/h264_hd_hls/segment_000.m4s", + "embed/v3/h264_hd_hls/segment_000.m4s", "qingb", "video/mp4", 12_345 @@ -1373,11 +1444,15 @@ defmodule MaveCore.Media.StorageTest do assert uri.scheme == "https" assert uri.host == "storage.example.test" - assert uri.path == "/space-qingb/embed/h264_hd_hls/segment_000.m4s" + assert uri.path == "/space-qingb/embed/v3/h264_hd_hls/segment_000.m4s" assert is_binary(params["X-Amz-Signature"]) - assert params["X-Amz-SignedHeaders"] == "content-length;content-type;host;x-amz-acl" + + assert params["X-Amz-SignedHeaders"] == + "cache-control;content-disposition;content-length;content-type;host;x-amz-acl" assert headers == %{ + "cache-control" => "public, max-age=31536000, immutable", + "content-disposition" => "inline", "content-length" => "12345", "content-type" => "video/mp4", "x-amz-acl" => "public-read" diff --git a/test/mave_core_web/controllers/api/playback_controller_test.exs b/test/mave_core_web/controllers/api/playback_controller_test.exs index 85ad5e3..8107151 100644 --- a/test/mave_core_web/controllers/api/playback_controller_test.exs +++ b/test/mave_core_web/controllers/api/playback_controller_test.exs @@ -39,8 +39,10 @@ defmodule MaveCoreWeb.Api.PlaybackControllerTest do end end - def presigned_get_url(bucket, path, _region, opts), - do: {:ok, "https://storage.example.test/#{bucket}/#{path}?expires=#{opts[:expires]}"} + def presigned_get_url(bucket, path, _region, opts) do + query = URI.encode_query([{"expires", opts[:expires]} | Keyword.get(opts, :query_params, [])]) + {:ok, "https://storage.example.test/#{bucket}/#{path}?#{query}"} + end setup do previous_origin = Application.get_env(:mave_core, :playback_origin) @@ -237,7 +239,10 @@ defmodule MaveCoreWeb.Api.PlaybackControllerTest do |> get(base <> "/v1/h264_hd.mp4") assert conn.status == 302 - assert hd(get_resp_header(conn, "location")) =~ "v1/h264_hd.mp4" + location = URI.parse(hd(get_resp_header(conn, "location"))) + assert location.path =~ "v1/h264_hd.mp4" + query = URI.decode_query(location.query) + assert query["response-cache-control"] == "private, max-age=#{query["expires"]}" {:ok, other} = Embeds.create_video_embed(context.space) wrong = sign(context.read_key, %{"sub" => context.space.hash <> other.hash})