All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[0.8.1] - 2026-08-20
Added
Responsive WebP variants. Responsive
srcsetvariants now inherit the served format. When a collection serves WebP, its variants are written to.webppaths and encoded as WebP (the image backend picks the encoder from the destination extension), and the full-sizesrcsetdescriptor reuses the already-generated WebP sibling, so the entiresrcsetis WebP at no extra cost. Non-WebP collections keep the original extension, unchanged.Per-collection responsive opt-in. A collection enables
srcsetgeneration on add via itsresponsive:field (bool or keyword), with widths overridable per-collection:media_collections do collection(:photos, webp: true, responsive: true) # WebP variants, global widths collection(:banners, webp: true, responsive: [widths: [640, 1280, 2560]]) endPhxMediaLibrary.ResponsiveImages.generate/3gains an optionalwidths:option (defaults to the global config), so per-collection widths flow through the add pipeline.
Changed
- On add, WebP generation now runs before responsive generation, so
variants inherit the
.webpencoding. - Responsive generation on add is opt-in per collection (
responsive:) or viawith_responsive_images/1. Unlike WebP, the globalresponsive_imagesconfig is not treated as an auto-enable for every collection. It supplies the default widths only. This preserves the historical behaviour where only an explicit request generated variants (no surprise generation for existing hosts that hadresponsive_images: [enabled: true]).
Fixed
- Responsive variant paths now derive from the configured path generator
instead of assuming the default
{type}/{id}/{uuid}layout. This respects custom/tenant/date generators and stringifies a non-binarymediable_id(e.g. an integer primary key). PreviouslyPath.joinraised when the id was not already a string.
[0.8.0] - 2026-08-20
Added
WebP conversion. A collection can transcode raster/HEIC uploads to WebP for faster loads and better SEO. Opt in globally or per-collection; every knob is overridable per-collection:
config :phx_media_library, webp: [enabled: false, quality: 82, keep_original: true] media_collections do collection(:photos, webp: true) # jpg/png/HEIC → served as WebP collection(:hero, webp: [quality: 90, keep_original: false]) endWith
keep_original: true(default) the source is kept;falseremoves it, but only after conversions run (conversions derive from the source, so they are generated first and the deletion is strictly last; safe to combine with named conversions). Either wayurl/2serves the WebP viacustom_properties["webp"], so<img src>needs no change. Requires the optional:image(libvips) dependency; features no-op when it is absent, and any conversion error falls back to serving the original. iPhone HEIC/HEIF uploads convert to browser-viewable WebP.mix phx_media_library.regenerate_webpre-derives WebP from each media's original using the current config (run after changingquality). Media stored withkeep_original: falsehave no source and are skipped with a warning.PhxMediaLibrary.Config.resolve_webp/1andresolve_responsive/1resolve effective per-collection settings (per-collection over global over defaults).PhxMediaLibrary.AsyncProcessor.Inlineis a synchronous conversion processor (runs conversions inline, noTask.Supervisor); handy for tests, scripts, or small apps. Enable withconfig :phx_media_library, async_processor: PhxMediaLibrary.AsyncProcessor.Inline.
Fixed
- Conversions now tolerate their media changing between enqueue and
processing.
Conversions.process/2re-reads the current row (skipping if the media was deleted), so it avoidsEcto.StaleEntryErroron the async path. - Conversions no longer crash (
Image.open(nil)→Enumerable not implemented) on disks without a local source path (:memory, S3).process/2andprocess_single/2skip (debug-logged) whenfull_pathisnil.
Notes
- WebP is opt-in; upgrading from 0.7.x changes nothing until a collection enables it.
- With
keep_original: false,file_name/mime_typestill describe the source (the WebP is served viacustom_properties["webp"]); the source file is removed after conversions.regenerate_webpskips such media (no source).
[0.7.0] - 2026-08-09
Added
Configurable
mediable_id_type. Themediable_idfield in theMediaschema now comes fromApplication.compile_env(:phx_media_library, :mediable_id_type, :binary_id). Supported values are:binary_id(default, no change for existing installs),:integer, and:string. This letsPhxMediaLibraryassociate media with models whose primary key is not a UUID, while remaining backwards-compatible with existing applications.Config.mediable_id_type/0returns the resolved compile-time value.Path generators coerced to string.
Default,DateBased, andTenantpath generators now callto_string/1onmediable_idbefore passing it toPath.join/1, so integer and non-string IDs no longer raiseFunctionClauseError.mix phx_media_library.install --id-typeis a new install flag that acceptsbinary_id(default),integer, orstring, and generates the migration with the matching column type formediable_id(:binary_id,:bigint, or:string). The--binary-id falseshorthand is still accepted for backwards compatibility and maps to--id-type integer.
Upgrade notes
The current default for mediable_id_type is :binary_id to keep all
existing installations working without any configuration change. In 0.8 the
default will change to :string. New apps should set
config :phx_media_library, mediable_id_type: :string today; they will
require no change when upgrading to 0.8. Existing apps that omit the key and
rely on the :binary_id default should add mediable_id_type: :binary_id
explicitly before upgrading to 0.8.
[0.6.0] - 2026-03-31
Added
4.1 BlurHash Generation
PhxMediaLibrary.Blurhashis a new module that generates BlurHash strings from image files. Uses the:imagelibrary (libvips) to resize images to a small working size and applies a pure-Elixir DCT encoder (base-83 alphabet, reference: blurha.sh). Optional. Disabled when:imageis not installed.Config.blurhash_enabled?/0returnstruewhenresponsive_images: [blurhash: true]is configured and the:imagelibrary is available.Automatic blurhash generation.
MediaAddernow generates a BlurHash string for every image upload whenblurhash_enabled?/0is true and stores it inmedia.responsive_images["blurhash"].Media.blurhash/1andPhxMediaLibrary.blurhash/1are convenience helpers that returnmedia.responsive_images["blurhash"]ornil.<PhxMediaLibrary.Components.blurhash>is a new function component that renders the hash as a<canvas>element. A colocated JavaScript hook (no npm dependency) decodes the hash client-side and paints the blurred preview for progressive loading before the real image arrives.
4.4a CDN URL Generation with Cache-Busting
url/3:cache_bustoption. Passcache_bust: trueto any URL-generating call to append?v={checksum[0..7]}to the URL. The fingerprint comes from the media item's stored SHA-256 checksum, so CDN edges serve a fresh copy whenever a file is replaced. Falls back to a plain URL when no checksum is stored.PhxMediaLibrary.cdn_url/2,Media.cdn_url/2, andUrlGenerator.cdn_url/2are convenience shorthand forurl(media, conversion, cache_bust: true).
4.4b Content-Disposition Download Links
url/3:downloadoption. Passdownload: trueto generate a URL that triggersContent-Disposition: attachmentin the browser.- S3. Generates a presigned GET URL with the
response-content-dispositionquery parameter included in the AWS Signature V4 canonical request (so the signature is valid). - Local disk. Routes through
PhxMediaLibrary.Plug.MediaDownload, which serves the file with the proper response header.
- S3. Generates a presigned GET URL with the
PhxMediaLibrary.download_url/3,Media.download_url/3, andUrlGenerator.download_url/3are shorthand forurl(media, conversion, download: true).PhxMediaLibrary.Plug.MediaDownloadis a new Plug for local-disk storage. Mount it in the Phoenix router at the path configured asdownload_base_url. Supports unsigned download links and HMAC-signed expiring URLs (see 4.4c below). Includes path-traversal protection.
4.4c Signed / Expiring URLs
url/3:signedand:expires_inoptions. Passsigned: trueto generate a time-limited URL.:expires_incontrols the expiry window in seconds (default:3600).- S3. AWS Signature V4 presigned GET URL (already supported internally;
now exposed through the
url/3API). - Local disk. HMAC-SHA256-signed URL that
PhxMediaLibrary.Plug.MediaDownloadverifies. Requiressecret_key_baseanddownload_base_urlin the disk config.
- S3. AWS Signature V4 presigned GET URL (already supported internally;
now exposed through the
PhxMediaLibrary.signed_url/3,Media.signed_url/3, andUrlGenerator.signed_url/3are shorthand forurl(media, conversion, signed: true).PhxMediaLibrary.SignedUrlis a new module that implements HMAC-SHA256 signing and constant-time verification for local-disk URLs.Config.secret_key_base/0andConfig.download_base_url/0are new config helpers for the global signing secret and download plug mount path.
4.3 Multi-Tenant Support
PhxMediaLibrary.PathGenerator.Tenantis a new built-in path generator that prepends atenant_idsegment to every storage path:{tenant_id}/{mediable_type}/{mediable_id}/{uuid}/{filename}. It reads thetenant_idfrom the optionalpath_contextmap (atom or string key); falls back to"shared"when absent. It coerces integer IDs to strings.The Multi-Tenant guide (
guides/multi-tenant.md) covers natural per-model scoping, configuringPathGenerator.Tenant, passingpath_contextthrough upload flows, cross-model queries, per-tenant storage backends, custom generators, and migrating existing files.Config.path_generator/0doc updated to listTenantalongsideDefault,Flat, andDateBased.
4.2 Optional FFmpeg Video Processor
PhxMediaLibrary.VideoProcessorbehaviour is a new pluggable behaviour for video processing adapters with three callbacks:available?/0,extract_metadata/1, andextract_poster/2.PhxMediaLibrary.VideoProcessor.FFmpegis an implementation that usesffprobe(metadata) andffmpeg(poster frames). Selected when both executables are on$PATH. No configuration required. Extracts:duration(float seconds),width,height,codec,fps,audio_codec,bit_rate.PhxMediaLibrary.VideoProcessor.Nullis a no-op fallback used when FFmpeg is not installed. Uploads still succeed; metadata and poster generation are skipped without errors.Config.video_processor/0returns the active video processor module; auto-detects FFmpeg at startup; configurable viaconfig :phx_media_library, video_processor: …Automatic video metadata extraction.
MetadataExtractor.Defaultnow delegates video file extraction to the configuredVideoProcessorand populatesmedia.metadatawith duration, dimensions, codec, and fps on every video upload when FFmpeg is available.Poster frame generation.
MediaAdderextracts a JPEG poster frame at 10% into the video (capped at 5 s) after upload and stores it alongside the video file. It records the URL inmedia.responsive_images["poster"]["url"]for use in templates.<.media_video>component is a newPhxMediaLibrary.Componentsfunction component that renders a styled<video>player with automatic poster frame, preload, and a metadata strip (duration, dimensions, codec, fps). Acceptscontrols,autoplay,muted,loop, andclassattributes.
Fixed
delete_files/1crash on media with responsive images. The responsive images delete loop incorrectly pattern-matched%{"path" => path}directly on top-level map values, which are actually%{"variants" => [...], "placeholder" => ...}structs. The loop now uses multi-clauseEnum.eachto handle both responsive image variant lists and poster frame entries (%{"path" => ..., "url" => ...}).data-confirminterpolation in gallery_app video delete button. The confirmation string used unescaped curly quotes around the filename, which caused a HEEx compile warning. Now uses proper\"escaping.
Changed
- Version bumped to
0.6.0. Covers the full Milestone 4 feature set (4.2 FFmpeg video processing, 4.3 multi-tenant path generator, 4.4/4.5/4.6 tooling and path generators delivered in Wave 1).
[0.5.1] - 2026-03-01
Added
Nested
collection ... doDSL for conversions. You can now nestconvertcalls inside acollection ... do ... endblock withinmedia_collections. The DSL scopes each conversion to the enclosing collection, so you need not pass:collectionsmanually. This is now the recommended style:media_collections do collection :images, max_files: 20 do convert :thumb, width: 150, height: 150, fit: :cover convert :preview, width: 800, quality: 85 end collection :documents, accepts: ~w(application/pdf) collection :avatar, single_file: true do convert :thumb, width: 150, height: 150, fit: :cover end endYou can mix the nested and flat styles. The DSL respects explicit
:collectionsoptions inside nested blocks. See the updated Collections & Conversions guide for details.PhxMediaLibrary.ModelRegistryis a new always-compiled module that discovers and caches the Ecto schema module for a givenmediable_typestring. Previously this logic lived insidePhxMediaLibrary.Workers.ProcessConversions(which is only compiled when Oban is installed), which caused warnings in themix phx_media_library.regeneratetask for projects without Oban. Both the Oban worker and the mix task now delegate toModelRegistry.PhxMediaLibrary.MediaLiveLiveComponent is a self-contained LiveComponent that encapsulates the entire media upload + gallery lifecycle. Eliminates all upload boilerplate: nouse PhxMediaLibrary.LiveUpload, nohandle_eventclauses, noallow_upload, noconsume_media. Just drop it into any LiveView template:<.live_component module={PhxMediaLibrary.MediaLive} id="post-images" model={@post} collection={:images} />Features: drag-and-drop upload zone, live image previews, progress bars, error display, cancel buttons, submit button, stream-powered media gallery with delete-on-hover, dark mode support. Configurable via
max_file_size,max_entries,responsive,upload_label,upload_sublabel,compact,columns,conversion,show_gallery, andclassoptions.Sends
{PhxMediaLibrary.MediaLive, {:uploaded, collection, media_items}}and{PhxMediaLibrary.MediaLive, {:deleted, collection, media}}messages to the parent LiveView for optional reaction.Restructured LiveView guide. Now leads with the zero-boilerplate
MediaLiveLiveComponent approach, with a dedicated "Custom Upload UI" section documenting how to build your own form with<.live_file_input>for full control. Includes a warning about the nested form gotcha with<.media_upload>.The
upload_class,gallery_class,button_classoptions forMediaLivelet consumers override the default Tailwind utility classes on the drop zone wrapper, gallery grid container, and submit button respectively. Whennil(default), MediaLive uses the built-in styles. When set, the value replaces the default classes, so you can integrate with component libraries like daisyUI (e.g.button_class="btn btn-primary w-full").
Fixed
Eliminated noisy ExAws/S3 compile warnings in consumer projects.
PhxMediaLibrary.Storage.S3is now wrapped inif Code.ensure_loaded?(ExAws.S3) do, the same patternImageProcessor.ImageandAsyncProcessor.Obanalready use. Consumer projects that don't use S3 will no longer see ~15ExAws.S3.* is undefinedwarnings during compilation.Eliminated
ProcessConversions.find_model_module/1 is undefinedwarning. Themix phx_media_library.regeneratetask previously referencedPhxMediaLibrary.Workers.ProcessConversions, which only exists when Oban is installed. The model lookup logic has been extracted intoPhxMediaLibrary.ModelRegistry(always compiled), and the Oban worker now delegates to it.ProcessConversions.find_model_module/1remains as adefdelegatefor backwards compatibility.Multi-file selection now works by default. Non-
single_filecollections without an explicitmax_filesnow default tomax_entries: 10, which enables themultipleattribute on the file input. Previously,max_entrieswas left unset, which caused Phoenix LiveView to default to 1 (single file picker). Collections withsingle_file: truestill limit to 1, andmax_files: Nstill maps tomax_entries: N.Upload progress bars are now visible on fast/local uploads. The component now renders the progress bar track as soon as a file is selected (
progress >= 0) instead of only whenprogress > 0. On local development, uploads complete almost instantly so the previous> 0 && < 100condition meant the bar was never visible. The bar now shows a subtle track at 0%, fills with blue as progress advances, displays a percentage label, and transitions to a "Ready" checkmark at 100%.
Changed
Updated all documentation to recommend nested DSL. The
HasMediamoduledoc, getting-started guide, and collections-and-conversions guide now show the nestedcollection ... do convert ... endstyle as the primary/recommended approach, with the flat style and function-based approach as alternatives. All examples explicitly scope conversions to collections.The LiveView guide's "How Upload Limits Are Derived" section documents how
max_entriesis derived from collection configuration (single_file: true→ 1,max_files: N→ N, otherwise 10) and how themax_entriescomponent option overrides it.The LiveView guide's "Customizing Styles" section documents the
upload_class,gallery_class, andbutton_classoptions with examples for daisyUI integration and custom styling.
[0.5.0] - 2026-02-27
Added
- Milestone 3c complete (717 tests passing, up from 653 in v0.4.0)
3.5 Soft Deletes
- Opt-in soft deletes.
config :phx_media_library, soft_deletes: trueenables soft deletes globally. Disabled by default, no behaviour change for existing users delete/1respects config. When soft deletes are enabled,delete/1setsdeleted_atinstead of removing the record and files. When disabled, behaviour is unchanged (hard delete)permanently_delete/1always performs a hard delete (removes files from storage and database record) regardless of the soft deletes configurationsoft_delete/1soft-deletes a media item by setting itsdeleted_attimestamp. Files are preserved in storage untilpermanently_delete/1orpurge_trashed/2is calledrestore/1restores a soft-deleted media item by clearingdeleted_attrashed?/1is a predicate to check whether a media item has been soft-deletedget_trashed_media/2queries only soft-deleted media for a model, optionally filtered by collection (inverse ofget_media/2)purge_trashed/2permanently deletes all trashed media for a model, with optional:beforecutoff for age-based cleanup (e.g.before: DateTime.add(DateTime.utc_now(), -30, :day))- Query scoping.
get_media/2,get_first_media/2,media_query/2, andMedia.for_model/2exclude soft-deleted records when soft deletes are enabled exclude_trashed/1andonly_trashed/1are query helpers onMediafor composing custom Ecto queriesclear_collection/2andclear_media/1respect soft deletes. When enabled, these setdeleted_atviaupdate_allinstead of deleting records. Files are preserved until purgemix phx_media_library.purge_deletedis a Mix task to permanently remove old soft-deleted media. Options:--days N(default: 30),--all,--dry-run,--yes- New migration.
add_deleted_at_to_mediaaddsdeleted_atcolumn with index - Install task updated.
mix phx_media_library.installnow includesdeleted_atcolumn and index from the start
3.6 Streaming Upload Support
- File streaming.
MediaAdderno longer loads entire files into memory viaFile.read!. It streams files to storage in 64 KB chunks withFile.stream!/2 - Single-pass checksum.
MediaAddercomputes the SHA-256 checksum during the stream (viaStream.map/2feeding:crypto.hash_update/2) instead of in a separate full-file read - Header-only MIME detection. The detector reads only the first 512 bytes for magic-byte MIME type detection, enough for all supported formats (including TAR at offset 257)
- Known issue resolved. "MediaAdder loads entire file into memory" is no longer applicable
3.7 Direct S3 Upload (Presigned URLs)
presigned_upload_url/3generates a presigned URL for direct client-to-S3 uploads. Returns{:ok, url, fields, upload_key}. Requires:filenameoption; supports:content_type,:expires_in,:max_sizecomplete_external_upload/4creates aMediadatabase record after the client uploads directly to storage. Requires:filename,:content_type,:size; supports:custom_properties,:checksum,:checksum_algorithmpresigned_upload_url/3callback. New optional callback onPhxMediaLibrary.Storagebehaviour. The S3 adapter implements it; Disk and Memory adapters return{:error, :not_supported}StorageWrapper.presigned_upload_url/3is an adapter-aware wrapper that checksfunction_exported?/3and returns{:error, :not_supported}for adapters without the callbackTelemetry.
complete_external_upload/4emits[:phx_media_library, :add, :start | :stop]events withsource_type: :external
Changed
MediaAdder.store_and_persist/6→store_and_persist/5. No longer receivesfile_contentas a parameter.MediaAddercomputes the checksum during streamingMediaAdder.read_and_detect_mime/1now reads only the first 512 bytes (header) instead of the entire file. Returns{:ok, file_info, header}instead of{:ok, file_info, file_content}Media.delete/1return type. Returns{:ok, media}when soft deletes are enabled (soft delete), or:okwhen disabled (hard delete)Mediaschema. Addeddeleted_atfield (:utc_datetime, defaultnil)Media.permanently_delete/1renamed from the previousdelete/1hard-delete implementation.delete/1now dispatches based on soft deletes configPhxMediaLibrary.Storagebehaviour. Added optionalpresigned_upload_url/3callback- Install task migration template. Now includes
deleted_atcolumn and index
[0.4.0] - 2026-02-27
Added
- Milestone 3b complete (653 tests passing, up from 529 in v0.3.0)
3b.1 Remote URL Sources (Enhanced)
- URL validation.
add_from_url/3now validates URL scheme (onlyhttp/httpsallowed), rejects missing hosts, and returns{:error, {:invalid_url, reason}}tuples forftp://,file://, or malformed URLs - Custom request headers.
add_from_url/3accepts:headersoption for authenticated downloads (e.g.headers: [{"Authorization", "Bearer token"}]) - Download timeout.
:timeoutoption sets a receive timeout for slow servers Download telemetry. New
[:phx_media_library, :download, :start | :stop | :exception]events with URL, size, and MIME type metadata- Source URL tracking. When media is added from a URL, the original URL is stored in
custom_properties["source_url"] - Broader success codes. Downloads now accept any 2xx status code (200-299), not only 200
3b.2 Automatic Metadata Extraction
PhxMediaLibrary.MetadataExtractoris a new behaviour for extracting file metadata withextract/3callbackPhxMediaLibrary.MetadataExtractor.Defaultis the default implementation that:- Extracts image dimensions (
width,height), alpha channel presence, and EXIF data via the:imagelibrary (when available) - Classifies files into type categories:
"image","video","audio","document","other" - Normalizes MIME subtypes to human-friendly format names (e.g.
"quicktime"→"mov","svg+xml"→"svg") - Sanitizes EXIF data for JSON serialization (handles binaries, tuples, atoms)
- Falls back when
:imageis not installed. No crash, just base metadata
- Extracts image dimensions (
metadatafield onMediaschema. New:mapfield (default%{}) storing extracted metadata; persisted as a JSON column- New migration.
add_metadata_to_mediamigration adds themetadatacolumn - Install task updated.
mix phx_media_library.installnow generates migrations withmetadata,checksum, andchecksum_algorithmcolumns included from the start - Auto-extraction in pipeline.
to_collection/3extracts metadata after MIME detection and before storage without_metadata/1is a new builder function to skip extraction for a specific upload:PhxMediaLibrary.without_metadata(adder)- Global disable.
config :phx_media_library, extract_metadata: falsedisables extraction globally - Custom extractor.
config :phx_media_library, metadata_extractor: MyApp.MetadataExtractorto use your own implementation - Non-fatal extraction. Extraction failures are logged as warnings but never block the upload; media is stored with an empty metadata map
- Timestamp tracking. Extracted metadata includes
"extracted_at"ISO 8601 timestamp
3b.3 Oban Conversion Queuing (Enhanced)
process_sync/2is a new synchronous processing callback onPhxMediaLibrary.AsyncProcessor.Obanthat delegates toConversions.process/2for immediate conversions without queueing- Expanded documentation. The Oban adapter now documents the full setup flow (deps, queue config, PhxMediaLibrary config), queue sizing guidance, and retry behaviour (max 3 attempts with exponential backoff)
Changed
MediaAdderstruct. Added:extract_metadatafield (default:truefromMetadataExtractor.enabled?/0)MediaAdder.to_collection/3pipeline now includes a metadata extraction step between content-type verification and storagestore_and_persist/6accepts a metadata map parameter and includes it in media attributesresolve_source/1now handles{:url, url, opts}three-element tuple for URL sources with optionssource_type/1handles{:url, _, _}pattern for URL sources with optionsMediaschema. Addedmetadatafield to@optional_fieldsin changeset
0.3.0 - 2026-02-27
Added
- Milestone 3a complete (529 tests passing: 325 unit + 17 Oban worker + 28 new M3a + 159 integration)
PhxMediaLibrary.Erroris a base exception struct with:message,:reason, and:metadatafields. Used byto_collection!/3and other bang functionsPhxMediaLibrary.StorageErroris an exception for storage operation failures with:operation,:path,:adapter, and:reasonfields. Auto-generates descriptive messages from contextPhxMediaLibrary.ValidationErroris an exception for pre-storage validation failures with:field,:value, and:constraintfields. Human-readable default messages for:file_too_large,:invalid_mime_type, and:content_type_mismatchreasons with automatic byte formatting (bytes/KB/MB)- Telemetry integration. The
PhxMediaLibrary.Telemetrymodule emits:telemetry.span/3events for all key operations:[:phx_media_library, :add, :start | :stop | :exception], media addition lifecycle[:phx_media_library, :delete, :start | :stop | :exception], media deletion lifecycle[:phx_media_library, :conversion, :start | :stop | :exception], image conversion processing[:phx_media_library, :storage, :start | :stop | :exception], storage adapter operations (put/get/delete/exists?)[:phx_media_library, :batch, :start | :stop | :exception], batch operations (clear, reorder)[:phx_media_library, :reorder], standalone event after successful reorder- All spans include
durationin stop measurements and debug-level Logger output
Telemetry.event/3is a standalone event emitter for one-shot notifications (e.g.:media_reordered):max_sizecollection option. Maximum file size in bytes. Validated before storage (not after). Returns{:error, {:file_too_large, actual_size, max_size}}.allow_media_upload/3derives it into the LiveView upload's:max_file_size:verify_content_typecollection option. Whentrue(default), verifies that file content matches its declared MIME type. Set tofalseto skip verification for collections that accept arbitrary contentPhxMediaLibrary.MimeDetectorbehaviour is pluggable content-based MIME type detection. Configurable via:mime_detectorapplication envPhxMediaLibrary.MimeDetector.Defaultis a built-in magic-bytes detector supporting 50+ file formats:- Images: JPEG, PNG, GIF, WebP, BMP, TIFF, ICO, AVIF, HEIC/HEIF, SVG
- Documents: PDF, RTF, Microsoft Office (legacy compound binary)
- Audio: MP3 (ID3v2 + frame sync), OGG, FLAC, WAV, AIFF, AAC, MIDI, M4A
- Video: MP4/M4V (ftyp brand detection for isom/iso2/mp41/mp42/dash/qt/3gp/3g2), AVI, MKV/WebM, FLV, QuickTime
- Archives: ZIP, GZIP, BZIP2, 7-Zip, RAR, XZ, TAR (ustar at offset 257), Zstandard
- Other: WASM, SQLite, ELF, Mach-O (32/64-bit, both endiannesses), PE (EXE/DLL), XML
MimeDetector.detect_with_fallback/2detects from content, falls back to extension viaMIME.from_path/1MimeDetector.verify/3compares detected content type against declared type. Returns:okor{:error, {:content_type_mismatch, detected, declared}}- Content-based MIME detection in upload pipeline.
MediaAddernow reads file content once, detects MIME type from magic bytes (primary) with extension fallback, then validates against collection accepts. Catches executables disguised as images, etc. PhxMediaLibrary.reorder/3reorders media items by ID list:PhxMediaLibrary.reorder(post, :images, [id3, id1, id2]). Uses a single database transaction. It ignores IDs not in the collection. Emits:batchand:reorderTelemetry eventsPhxMediaLibrary.move_to/2moves a single media item to a specific 1-based position:PhxMediaLibrary.move_to(media, 1). Clamps to collection size. Re-numbers all siblings in the collection:telemetrydependency. Added{:telemetry, "~> 1.0"}as a required dependency
Changed
clear_collection/2now returns{:ok, count}. Previously returned:ok. Now uses a singledelete_allquery instead of N+1 individual deletes. Files are still deleted from storage individually before the batch DB delete. Emits[:phx_media_library, :batch]Telemetry eventsclear_media/1now returns{:ok, count}. Same batch optimization and return type change asclear_collection/2to_collection!/3raisesPhxMediaLibrary.Error. Previously raisedRuntimeError. Now raises a structuredPhxMediaLibrary.Errorwith:reasonset to:add_failedand:metadatacontaining:collectionand:original_error- MIME type detection is now content-based.
MediaAdderdetects MIME from file content (magic bytes) as primary, falling back to extension. Previously relied solely on file extension viaMIME.from_path/1 StorageWrapperemits Telemetry events. All storage operations (put/get/delete/exists?) are now wrapped inTelemetry.span/3, which provides timing and operation metadataConversions.process_conversion/5emits Telemetry events. Each individual conversion is wrapped in a[:phx_media_library, :conversion]spanMedia.delete/1emits Telemetry events. Wrapped in a[:phx_media_library, :delete]spanMediaAdder.to_collection/3emits Telemetry events. Wrapped in a[:phx_media_library, :add]span with:collection,:source_type, and:modelmetadataallow_media_upload/3derives:max_file_sizefrom collection. When a collection has:max_sizeconfigured,allow_media_upload/3passes it as:max_file_sizetoPhoenix.LiveView.allow_upload/3. Falls back to 10 MB default
Fixed
clear_collection/2was N+1. Fetched all media, then deleted one-by-one. Now deletes files from storage, then removes all DB records in a singledelete_allquery withEcto.Query.exclude(:order_by)to satisfy Ecto'sdelete_allconstraintsclear_media/1was N+1. Same fix asclear_collection/2MediaAdderread file content twice. PreviouslyFile.read!happened instore_and_persistfor both storage and checksum. Now reads once inread_and_detect_mime/1and threads the content through the pipeline
0.2.0 - 2026-02-27
Added
- Milestones 1 & 2 complete (370 tests passing: 297 unit + 17 Oban worker + 56 integration)
PhxMediaLibrary.HasMediadeclarative DSL provides schema-level configuration viamedia_collections do ... endandmedia_conversions do ... endmacro blocks as an alternative to the function-based approach. Both styles are supported and can be mixed.convert/2alias reads naturally in DSL context. Backed byCollectionAccumulatorandConversionAccumulatorcompile-time attribute accumulators, injected via__before_compile__withdefoverridablehas_media()macro injects polymorphichas_many. Callinghas_media()inside a schema block now injects a realhas_many :mediaassociation usingEcto.Schema.__has_many__/4directly (bypassing macro-expansion timing constraints). Uses:whereformediable_typescoping and:defaultsfor auto-populating onbuild. Collection-scoped variants viahas_media(:images)add scoped associations (e.g.has_many :imagesfiltered by bothmediable_typeandcollection_name). Enables standardRepo.preload(post, [:media, :images, :documents, :avatar])PhxMediaLibrary.media_query/2is a composableEcto.Querybuilder for a model's media, optionally filtered by collection. Supports further composition withwhere/3,limit/2, etc.PhxMediaLibrary.verify_integrity/1delegates toMedia.verify_integrity/1to verify a stored file's checksum against the database record. Returns:ok,{:error, :checksum_mismatch}, or{:error, :no_checksum}Media.compute_checksum/2computes SHA-256, SHA-1, or MD5 checksums for binary content. Used during upload and integrity verification- Checksum fields on
Mediaschema.checksum(string) andchecksum_algorithm(string, default"sha256") fields.MediaAdder.store_and_persist/4computes SHA-256 before writing the file to storage. Migration20240101000002_add_checksum_to_media.exsadds columns and index PhxMediaLibrary.ImageProcessor.Nullis a no-op image processor for when no image processing library is installed. All operations return{:error, {:no_image_processor, message}}with a message telling the developer to install:imageConfig.image_processor/0auto-detection. Defaults toImageProcessor.Imagewhen:imageis available, falls back toImageProcessor.Nullotherwise- Polymorphic type derivation from Ecto table name.
__media_type__/0now defaults to__schema__(:source)(e.g."posts","blog_categories"). Override viause PhxMediaLibrary.HasMedia, media_type: "custom"or by definingdef __media_type__, do: "custom". Replaces the broken naive pluralization ("categorys") - Oban worker resolves full Conversion definitions.
Workers.ProcessConversionsnow storesmediable_typein job args, discovers the originating schema module viafind_model_module/1(withpersistent_termcache), retrieves fullConversionstructs from the model'sget_media_conversions/1, and filters by requested names. Handles legacy job args Config.disk_config/1safe string-to-atom resolution. No longer usesString.to_existing_atom/1, which crashes on unknown atoms. Now iterates configured disk keys and compares stringsPathGenerator.full_path/2usesCode.ensure_loaded/1+function_exported?/3. Replaces fragileKeyword.keys(__info__(:functions))pattern for checking optionalpath/2callback- 56 integration tests against real Postgres. Full lifecycle tests in
test/phx_media_library/integration_test.exsusingEcto.Adapters.SQL.Sandbox. Tagged with@moduletag :dband auto-excluded when Postgres is unavailable. Covers: add→store→retrieve→delete, collection MIME validation, single_file replacement, max_files enforcement, ordering, checksum integrity and tamper detection, polymorphic type scoping,has_manypreloading,media_query/2composability, clear/delete operations, error paths, disk and memory storage adapters, concurrent access, JSON field round-trips, and unique UUID constraints - Test infrastructure.
test_helper.exsstartsTestRepo, runs migrations programmatically, configures SQL Sandbox.DataCasemodule provides sandbox setup anderrors_on/1helper.NoOpProcessorsuppresses background task noise in integration tests PhxMediaLibrary.Componentsare ready-to-use Phoenix LiveView function components for media uploads and galleries<.media_upload>is a drop-in upload zone with drag-and-drop, live image previews, progress bars, per-entry error display, and cancel buttons. Supports full-size and compact layouts, dark mode, and full slot/attr customization<.media_gallery>is a stream-powered gallery grid for displaying existing media with delete-on-hover, image thumbnails, document type icons, configurable columns (2-6), and:item/:emptyslots for custom rendering<.media_upload_button>is a compact inline upload button for embedding within forms or tight layouts- Colocated
.MediaDropZoneJS hook for drag-and-drop visual feedback (drag enter/leave tracking, drop flash animation) - File type icon mapping (video, audio, PDF, spreadsheet, archive, etc.)
PhxMediaLibrary.LiveUploadis ause-able helper module that imports upload lifecycle functions into any LiveViewallow_media_upload/3wrapsPhoenix.LiveView.allow_upload/3with collection-aware defaults: auto-derives:acceptfrom collection MIME types,:max_entriesfromsingle_file/max_files, and:max_file_size(default 10 MB)consume_media/5wrapsconsume_uploaded_entries/3and persists each entry viaPhxMediaLibrary.add/2 |> to_collection/2stream_existing_media/4loads existing media for a model/collection into a LiveView stream with"media-"prefixed DOM IDsstream_media_items/3inserts newly created media items into an existing streamdelete_media_by_id/2fetches and deletes a media record by ID (files + DB)media_upload_errors/1,media_entry_errors/2translate Phoenix upload error atoms into human-readable stringshas_upload_entries?/1,image_entry?/1are introspection helpers for conditional UI renderingtranslate_upload_error/1is extensible error translation with coverage of all built-in Phoenix upload errors
- Media lifecycle event notifications.
consume_media/5anddelete_media_by_id/2accept a:notifyoption (a pid). When set, sends{:media_added, media_items},{:media_error, reason}, or{:media_removed, media}to the target process, so parent LiveViews can react viahandle_info/2 - 17 Oban worker tests. Dedicated test suite in
test/phx_media_library/workers/process_conversions_test.exsusingOban.Testing.perform_job/3. Covers: missing media discard, full conversion resolution from model definitions (with dimensions/quality/fit), collection-scoped conversions, legacy job args fallback, unknown mediable_type fallback to name-only conversions, model module discovery andpersistent_termcaching, explicit model registry, and job changeset construction mix phx_media_library.regeneratemodel module discovery. The regenerate task now usesProcessConversions.find_model_module/1to resolve the model module frommediable_type, so it can retrieve full conversion definitions instead of returning an empty list- Dialyzer ignore file.
.dialyzer_ignore.exssuppresses known false positives forMix.shell/0,Mix.Task.run/1, andMix.Taskcallback info across all mix tasks (:mixis not in the production PLT)
Changed
:imagedependency is now optional. Markedoptional: trueinmix.exs.ImageProcessor.Imagemodule is wrapped inif Code.ensure_loaded?(Image)and only compiled when:imageis available. Library works for file storage without libvips installedmax_filescollection cleanup now keeps newest items. PreviouslyEnum.drop(max)on ascending-ordered list incorrectly deleted the newest item. Now keeps the newestmaxitems and deletes the oldest excessdelete_media_by_id/1→delete_media_by_id/2. Now accepts an optional keyword list with:notifyoption. The 1-arity form still works (defaults to no notification)mix precommitalias runs tests in correct environment. Usescmd --cd . sh -c 'MIX_ENV=test mix test'instead of bare"test", which failed with an environment mismatch error- Credo --strict passes clean. Refactored 13 functions across 9 files to resolve all nesting-depth and cyclomatic-complexity violations. Extracted helpers in
Config,Conversions,ResponsiveImages,ImageProcessor.Image,HasMedia.__before_compile__,Workers.ProcessConversions, and all mix tasks. Replaced TODO tag with descriptive comment - Dialyzer passes clean. Added
.dialyzer_ignore.exsfor known Mix PLT false positives. Fixed dead-code pattern inmix phx_media_library.regenerate(get_model_module/1now resolves modules instead of always returningnil)
Fixed
max_filesenforcement deleted wrong items.maybe_cleanup_collectioninMediaAdderusedEnum.drop(max), which removed the newest uploads instead of the oldest. Now usesEnum.take(excess_count)to delete the oldest excess items, keeping themaxmost recentConfig.disk_config/1crash on string disk names.String.to_existing_atom/1crashed when the atom hadn't been referenced yet. Now iterates configured disk keys and matches by string comparisonPathGenerator.full_path/2fragile function check. ReplacedKeyword.keys(__info__(:functions))withCode.ensure_loaded/1+function_exported?/3for optional callback detection- Polymorphic type derivation was naive.
get_mediable_type/1appended "s" to module name (producing"categorys"forCategory). Now derives from Ecto table name (__schema__(:source)) with configurable overrides - Oban worker created empty Conversion structs. Worker only serialized conversion names, losing dimensions/quality/format. Now stores
mediable_typein job args, discovers the model module, and retrieves fullConversiondefinitions has_media()macro was a no-op. Did not inject any Ecto association. Now injects a polymorphichas_manyviaEcto.Schema.__has_many__/4with:whereand:defaultsfor proper scoping- Credo alias ordering. Fixed alphabetical ordering of alias groups in
PathGenerator,UrlGenerator,AsyncProcessor,ResponsiveImages,Components,Fixtures, andPathGeneratorTest ImageProcessor.Image.save/3simplified. Extractedwrite_opts_for_format/2to eliminate nestedcaseinsidesave/3, reducing cyclomatic complexityImageProcessor.Image.maybe_resize/2flattened. Replaced nestedcaseon fit mode with multiple function clauses for:crop,:contain/:cover/:fill, and defaultConfig.disk_config/1simplified. Extractedresolve_disk_key/2andlookup_disk/2to reduce cyclomatic complexity from 11 to under 9HasMedia.__before_compile__/1decomposed. Extractedbuild_media_type_def/2,build_helpers/0, andbuild_dsl_defs/4private functions to reduce nesting depthWorkers.ProcessConversions.resolve_conversions/3flattened. Extractedget_model_conversions/2helper to eliminate nestedif/function_exported?checks- Mix task refactoring.
clean.ex: extractedreport_orphaned_files/3,delete_or_report_file/3,find_orphaned_records/2,report_orphaned_records/3,delete_or_report_record/3.regenerate.ex: extractedconversions_for_media/2,run_or_report/4,do_regenerate/4; usedEnum.map_join/3instead ofEnum.map/2 |> Enum.join/2.regenerate_responsive.ex: extractedbuild_responsive_query/2,process_item/4,update_responsive_images/3 ResponsiveImages.generate/2decomposed. Extractedgenerate_variants/7,build_responsive_data/7,maybe_generate_placeholder/2to reduce nesting depth. Extractedgenerate_conversion_data/1andgenerate_single_conversion_data/2fromgenerate_all/1
0.1.1 - 2026-02-24
Fixed
- Fixed
Image.write/2return value handling. Now handles the{:ok, image}tuple - Fixed
Image.thumbnail/2syntax to use proper keyword list options - Fixed responsive images generation to handle the Image library API
- Fixed conversions processor to destructure Image operation results
- Fixed path generator to handle conversion paths with proper defaults
0.1.0 - 2026-02-24
Added
- Initial release of PhxMediaLibrary
- Core functionality
- Associate media files with any Ecto schema via polymorphic associations
- Fluent API for adding media (
add/2,add_from_url/2,to_collection/3) - Custom filename support with
using_filename/2 - Custom properties/metadata with
with_custom_properties/2
- Collections
- Organize media into named collections
- MIME type validation with
:acceptsoption - Single file collections with
:single_fileoption - Maximum file limits with
:max_filesoption - Per-collection storage disk configuration
- Fallback URLs for empty collections
- Image conversions
- Automatic thumbnail and preview generation
- Configurable width, height, quality, and format
- Multiple fit modes:
:contain,:cover,:fill,:crop - Collection-specific conversions
- Responsive images
- Automatic srcset generation at configurable widths
- Tiny placeholder generation for progressive loading
with_responsive_images/1to enable per-media
- Storage backends
PhxMediaLibrary.Storage.Disk, local filesystem storagePhxMediaLibrary.Storage.S3, Amazon S3 and compatible servicesPhxMediaLibrary.Storage.Memory, in-memory storage for testingPhxMediaLibrary.Storagebehaviour for custom adapters
- Async processing
PhxMediaLibrary.AsyncProcessor.Task, simple Task-based processingPhxMediaLibrary.AsyncProcessor.Oban, Oban-based job processingPhxMediaLibrary.AsyncProcessorbehaviour for custom processors
- Phoenix view helpers
<.media_img>, simple image rendering<.responsive_img>, responsive image with srcset and placeholder<.picture>, picture element for art direction
- Mix tasks
mix phx_media_library.installgenerates a migration and prints setup instructionsmix phx_media_library.regenerateregenerates conversions for existing mediamix phx_media_library.regenerate_responsiveregenerates responsive imagesmix phx_media_library.cleanfinds and removes orphaned filesmix phx_media_library.gen.migrationgenerates custom migrations