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.6.0] - 2026-03-31
Added
4.1 — BlurHash Generation
PhxMediaLibrary.Blurhash— 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 — silently disabled when:imageis not installed.Config.blurhash_enabled?/0— returnstruewhenresponsive_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. The hash is stored inmedia.responsive_images["blurhash"].Media.blurhash/1andPhxMediaLibrary.blurhash/1— convenience helpers that returnmedia.responsive_images["blurhash"]ornil.<PhxMediaLibrary.Components.blurhash>— 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, providing smooth 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 automatically 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/2— 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.MediaDownloadwhich 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/3— shorthand forurl(media, conversion, download: true).PhxMediaLibrary.Plug.MediaDownload— 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 cleanly exposed through the unified
url/3API). - Local disk: HMAC-SHA256–signed URL verified by
PhxMediaLibrary.Plug.MediaDownload. Requiressecret_key_baseanddownload_base_urlin the disk config.
- S3: AWS Signature V4 presigned GET URL (already supported internally;
now cleanly exposed through the unified
PhxMediaLibrary.signed_url/3,Media.signed_url/3, andUrlGenerator.signed_url/3— shorthand forurl(media, conversion, signed: true).PhxMediaLibrary.SignedUrl— new module implementing HMAC-SHA256 signing and constant-time verification for local-disk URLs.Config.secret_key_base/0andConfig.download_base_url/0— new config helpers for the global signing secret and download plug mount path.
4.3 — Multi-Tenant Support
PhxMediaLibrary.PathGenerator.Tenant— new built-in path generator that prepends atenant_idsegment to every storage path:{tenant_id}/{mediable_type}/{mediable_id}/{uuid}/{filename}. Thetenant_idis read from the optionalpath_contextmap (atom or string key); falls back to"shared"when absent. Integer IDs are coerced to strings automatically.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 — new pluggable behaviour for video processing adapters with three callbacks:available?/0,extract_metadata/1, andextract_poster/2.PhxMediaLibrary.VideoProcessor.FFmpeg— implementation usingffprobe(metadata) andffmpeg(poster frames). Selected automatically when both executables are found on$PATH. No configuration required. Extracts:duration(float seconds),width,height,codec,fps,audio_codec,bit_rate.PhxMediaLibrary.VideoProcessor.Null— no-op fallback used when FFmpeg is not installed. Uploads still succeed; metadata and poster generation are simply skipped without errors.Config.video_processor/0— returns 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 configuredVideoProcessor, populatingmedia.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) immediately after upload and stores it alongside the video file. The URL is recorded inmedia.responsive_images["poster"]["url"]for use in templates.<.media_video>component — 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 correctly 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, causing 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. Each conversion is automatically scoped to the enclosing collection — no need to 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 endThe nested and flat styles can be mixed freely. Explicit
:collectionsoptions inside nested blocks are respected. See the updated Collections & Conversions guide for details.PhxMediaLibrary.ModelRegistry— 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), causing warnings in themix phx_media_library.regeneratetask for projects without Oban. Both the Oban worker and the mix task now delegate toModelRegistry.PhxMediaLibrary.MediaLiveLiveComponent — 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 clear warning about the nested form gotcha with<.media_upload>.upload_class,gallery_class,button_classoptions forMediaLive— allows consumers to override the default Tailwind utility classes on the drop zone wrapper, gallery grid container, and submit button respectively. Whennil(default), the built-in styles are used. When set, the value replaces the default classes entirely, enabling seamless integration 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, matching the pattern already used byImageProcessor.ImageandAsyncProcessor.Oban. 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.ProcessConversionswhich 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, enabling 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 correctly limit to 1, andmax_files: Nstill maps tomax_entries: N.Upload progress bars are now visible on fast/local uploads — the progress bar track is rendered 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.LiveView guide — "How Upload Limits Are Derived" section — documents how
max_entriesis automatically derived from collection configuration (single_file: true→ 1,max_files: N→ N, otherwise 10) and how themax_entriescomponent option overrides it.LiveView guide — "Customizing Styles" section — documents the
upload_class,gallery_class, andbutton_classoptions with examples for daisyUI integration and fully 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/1— Always performs a hard delete (removes files from storage and database record) regardless of the soft deletes configurationsoft_delete/1— Explicitly soft-delete a media item by setting itsdeleted_attimestamp. Files are preserved in storage untilpermanently_delete/1orpurge_trashed/2is calledrestore/1— Restore a soft-deleted media item by clearingdeleted_attrashed?/1— Predicate to check whether a media item has been soft-deletedget_trashed_media/2— Query only soft-deleted media for a model, optionally filtered by collection (inverse ofget_media/2)purge_trashed/2— Permanently delete 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/2automatically exclude soft-deleted records when soft deletes are enabled exclude_trashed/1andonly_trashed/1— 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_deleted— 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!. Files are streamed to storage in 64 KB chunks usingFile.stream!/2 - Single-pass checksum — SHA-256 checksum is computed during the stream (via
Stream.map/2feeding:crypto.hash_update/2) instead of a separate full-file read - Header-only MIME detection — Only the first 512 bytes are read for magic-byte MIME type detection, sufficient 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/3— Generate 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/4— Create 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. S3 adapter implements it; Disk and Memory adapters return{:error, :not_supported}StorageWrapper.presigned_upload_url/3— 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. Checksum is computed during streamingMediaAdder.read_and_detect_mime/1— Now 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/1— Renamed 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 descriptive{: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 automatically stored in
custom_properties["source_url"] - Broader success codes — Downloads now accept any 2xx status code (200–299), not just 200
3b.2 — Automatic Metadata Extraction
PhxMediaLibrary.MetadataExtractor— New behaviour for extracting file metadata withextract/3callbackPhxMediaLibrary.MetadataExtractor.Default— 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)
- Gracefully 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/3automatically extracts metadata after MIME detection and before storage without_metadata/1— 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/2— Added synchronous processing callback toPhxMediaLibrary.AsyncProcessor.Oban, delegating toConversions.process/2for immediate conversions without queueing- Enhanced documentation — Oban adapter now documents 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/3— Pipeline now includes metadata extraction step between content-type verification and storagestore_and_persist/6— Accepts metadata map parameter and includes it in media attributesresolve_source/1— Now handles{:url, url, opts}three-element tuple for URL sources with optionssource_type/1— Handles{: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.Error— Base exception struct with:message,:reason, and:metadatafields. Used byto_collection!/3and other bang functionsPhxMediaLibrary.StorageError— Exception for storage operation failures with:operation,:path,:adapter, and:reasonfields. Auto-generates descriptive messages from contextPhxMediaLibrary.ValidationError— 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 —
PhxMediaLibrary.Telemetrymodule emitting: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/3— 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}}. Automatically derived into LiveView upload's:max_file_sizeviaallow_media_upload/3: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 — Pluggable content-based MIME type detection. Configurable via:mime_detectorapplication envPhxMediaLibrary.MimeDetector.Default— 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/2— Detects from content, falls back to extension viaMIME.from_path/1MimeDetector.verify/3— Compares 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/3— Reorder media items by ID list:PhxMediaLibrary.reorder(post, :images, [id3, id1, id2]). Uses a single database transaction. IDs not in the collection are silently ignored. Emits:batchand:reorderTelemetry eventsPhxMediaLibrary.move_to/2— Move 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, providing 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, it's automatically passed 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 — 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/2— ComposableEcto.Querybuilder for a model's media, optionally filtered by collection. Supports further composition withwhere/3,limit/2, etc.PhxMediaLibrary.verify_integrity/1— Delegates 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/2— Computes 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. SHA-256 computed automatically duringMediaAdder.store_and_persist/4before file is written to storage. Migration20240101000002_add_checksum_to_media.exsadds columns and index PhxMediaLibrary.ImageProcessor.Null— No-op image processor for when no image processing library is installed. All operations return{:error, {:no_image_processor, message}}with a clear message guiding 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 gracefully Config.disk_config/1safe string-to-atom resolution — No longer usesString.to_existing_atom/1which 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.Components— Ready-to-use Phoenix LiveView function components for media uploads and galleries<.media_upload>— 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>— 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>— Compact inline upload button for embedding within forms or tight layouts- Colocated
.MediaDropZoneJS hook for enhanced drag-and-drop visual feedback (drag enter/leave tracking, drop flash animation) - File type icon mapping (video, audio, PDF, spreadsheet, archive, etc.)
PhxMediaLibrary.LiveUpload—use-able helper module that imports upload lifecycle functions into any LiveViewallow_media_upload/3— WrapsPhoenix.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/5— Wrapsconsume_uploaded_entries/3and persists each entry viaPhxMediaLibrary.add/2 |> to_collection/2stream_existing_media/4— Loads existing media for a model/collection into a LiveView stream with"media-"prefixed DOM IDsstream_media_items/3— Inserts newly created media items into an existing streamdelete_media_by_id/2— Fetches and deletes a media record by ID (files + DB)media_upload_errors/1,media_entry_errors/2— Translates Phoenix upload error atoms into human-readable stringshas_upload_entries?/1,image_entry?/1— Introspection helpers for conditional UI renderingtranslate_upload_error/1— 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, enabling parent LiveViews to 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, enabling it to 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 correctly 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 robust 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 correctly handles{:ok, image}tuple - Fixed
Image.thumbnail/2syntax to use proper keyword list options - Fixed responsive images generation to handle Image library API correctly
- Fixed conversions processor to properly 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.install- Generate migration and print setup instructionsmix phx_media_library.regenerate- Regenerate conversions for existing mediamix phx_media_library.regenerate_responsive- Regenerate responsive imagesmix phx_media_library.clean- Find and remove orphaned filesmix phx_media_library.gen.migration- Generate custom migrations