Shared utilities for MediaKit storage providers — key generation, resilience pipelines, and file name sanitization.
$ dotnet add package MediaKit.Storage.AbstractionsPolymorphic media management library for .NET — file attachments, multi-provider storage, image processing, and background conversions without owning your app architecture.
MediaKit gives you production-grade media handling for any .NET API. Attach files to any entity, store them on local disk or S3-compatible services, generate image conversions, and query media with EF Core or Dapper — all with a fluent builder API. It fills the Spatie Media Library gap for the .NET ecosystem.
| Package | Description | Target |
|---|---|---|
| MediaKit | Core types — media entity, interfaces, DTOs, validation, extensions. Zero ASP.NET dependency. | net8.0, net9.0, net10.0 |
| MediaKit.AspNetCore | ASP.NET Core integration — MediaService, ImageSharp processing, background workers, file validation, DI registration. | net8.0, net10.0 |
| MediaKit.EntityFrameworkCore | EF Core integration — generic entity configuration, query extensions, media eager loading. | net8.0, net10.0 |
| MediaKit.Dapper | Dapper integration — type handlers, repository, query extensions for high-performance media access. | net8.0, net9.0, net10.0 |
| MediaKit.Storage | Meta-package — includes S3 and Local providers for convenience. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.S3 | S3-compatible storage — AWS S3, SeaweedFS, RustFS with Polly resilience. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.Local | Local filesystem storage with JWT signed URLs. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.AzureBlobs | Azure Blob Storage with SAS signed URLs and Polly resilience. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.Abstractions | Shared utilities — key generation, resilience pipelines, file name sanitization. | net8.0, net9.0, net10.0 |
# Core (required)
dotnet add package MediaKit
# ASP.NET Core integration (required for web apps)
dotnet add package MediaKit.AspNetCore
# EF Core entity configuration and query helpers
dotnet add package MediaKit.EntityFrameworkCore
# Dapper integration (alternative to EF Core)
dotnet add package MediaKit.Dapper
# Storage — pick what you need:
dotnet add package MediaKit.Storage # Meta-package (S3 + Local)
dotnet add package MediaKit.Storage.S3 # S3 / SeaweedFS / RustFS only
dotnet add package MediaKit.Storage.Local # Local filesystem only
dotnet add package MediaKit.Storage.AzureBlobs # Azure Blob Storageusing MediaKit.AspNetCore.DependencyInjection;
using MediaKit.Collections;
using MediaKit.Entities;
using MediaKit.Storage.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Register MediaKit with local storage and ImageSharp
builder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
options.UseBackgroundProcessing = true;
})
.AddLocalStorage(config =>
{
config.RootPath = "wwwroot/storage";
config.BaseUrl = "/storage";
})
.AddImageSharp();
// Register collection metadata
MediaCollectionRegistry.Register<Product, Media>(Product.GetCollectionMetadata());
var app = builder.Build();
app.MapPost("/api/products/{id}/cover", async (
int id, IFormFile file, AppDbContext db,
MediaService<AppDbContext, Media> mediaService) =>
{
var product = await db.Products.FindAsync(id);
var media = await mediaService.UploadAsync(product!, file, "cover");
return Results.Ok(new { media.Id, media.Url, media.Conversions });
});
app.Run();The Media class is the base entity for all file attachments. It uses a polymorphic design (EntityId + EntityType) to attach to any entity without foreign keys:
public class Media
{
public string Id { get; set; } // Auto-generated GUID
public string FileName { get; set; } // Storage filename
public string OriginalName { get; set; } // User-facing filename
public string MimeType { get; set; } // Content type
public string Path { get; set; } // Storage path
public string Url { get; set; } // Public URL
public long Size { get; set; } // File size in bytes
public string Collection { get; set; } // "cover", "gallery", "documents"
public string Disk { get; set; } // "local", "s3", "seaweedfs"
public int SortOrder { get; set; } // Ordering within collection
public string? EntityId { get; set; } // Polymorphic owner ID
public string? EntityType { get; set; } // Polymorphic owner type
// JSON-serialized dictionaries
public Dictionary<string, string> Conversions { get; set; } // "thumbnail" => "/storage/.../thumb.webp"
public Dictionary<string, object> CustomProperties { get; set; } // width, height, etc.
}Extend Media with a derived class for app-specific properties:
public class AppMedia : Media
{
public string? AltText { get; set; }
public string? Caption { get; set; }
}Any entity can have media by implementing IHasMedia<TMedia>:
public class Product : IHasMedia<Media>
{
public int Id { get; set; }
public string Name { get; set; } = "";
// IHasMedia implementation
object IHasMedia<Media>.Id => Id;
public static string MediaEntityType => "product";
public ICollection<Media>? Media { get; set; }
}
public class Author : IHasMedia<Media>
{
public int Id { get; set; }
public string Name { get; set; } = "";
object IHasMedia<Media>.Id => Id;
public static string MediaEntityType => "author";
public ICollection<Media>? Media { get; set; }
}The MediaEntityType string is stored in the entity_type column, enabling queries like "get all media for product 42" without a direct FK.
Collections organize media by purpose. Define collection behavior with IHasMediaCollections:
public class Product : IHasMedia<Media>, IHasMediaCollections
{
// ...
public static IReadOnlyDictionary<string, MediaCollectionMetadata> GetCollectionMetadata() =>
new Dictionary<string, MediaCollectionMetadata>
{
// Single-item: uploading replaces existing media
["cover"] = new(IsSingleItem: true, MaxItems: 1, Description: "Product cover image"),
// Multi-item: uploading appends to collection
["gallery"] = new(IsSingleItem: false, MaxItems: 10, Description: "Product gallery"),
// Documents collection
["documents"] = new(IsSingleItem: false, MaxItems: 5, Description: "Product documents"),
// Private collection — requires signed URLs for access
["invoices"] = new(IsSingleItem: false, MaxItems: 20, Description: "Private invoices", RequiresSignedUrl: true)
};
}Register metadata at startup:
MediaCollectionRegistry.Register<Product, Media>(Product.GetCollectionMetadata());RequiresSignedUrl flag: Collections default to RequiresSignedUrl: false, meaning media is served via public URLs. Set RequiresSignedUrl: true on collections that contain private content (invoices, contracts, etc.) — consumers can use this flag to decide whether to generate signed URLs or return direct public URLs.
Instead of calling product.GetFirstMedia("cover") (O(n) LINQ scan every time), define typed properties with cached O(1) access using MediaCollectionView<TMedia>:
using MediaKit.Collections;
using MediaKit.Entities;
public class Product : IHasMedia<Media>, IHasMediaCollections
{
public int Id { get; set; }
public string Name { get; set; } = "";
// IHasMedia — unchanged
object IHasMedia<Media>.Id => Id;
public static string MediaEntityType => "product";
public ICollection<Media>? Media { get; set; }
// Cached collection view — single O(n) pass indexes all collections
private MediaCollectionView<Media>? _mediaView;
private MediaCollectionView<Media> MediaView
=> _mediaView ??= new(Media);
// Typed properties — O(1) cached lookup
[MediaCollection("cover", IsSingle = true)]
public Media? Cover => MediaView.GetFirst("cover");
[MediaCollection("gallery")]
public IReadOnlyList<Media> Gallery => MediaView.GetAll("gallery");
// ... collection metadata, conversions, etc.
}How it works:
MediaCollectionView<TMedia> builds a Dictionary<string, List<TMedia>> index in a single O(n) pass on first access to any method[MediaCollection] attribute decorates typed properties so EF Core automatically ignores them (no shadow columns or FKs)Array.Empty<TMedia>() is returned for missing collections (singleton, zero allocation)Collection-specific database loading:
// Only load cover media from DB — not gallery, not everything
var products = await db.Products
.IncludeMedia<Product, Media>("cover")
.ToListAsync();
// product.Cover is populated, product.Gallery is empty (not loaded)
var coverUrl = products[0].Cover?.Url;
// Load specific collections
var products = await db.Products
.IncludeMedia<Product, Media>("cover", "gallery")
.ToListAsync();
// Load via property expression (resolves collection name from [MediaCollection] attribute)
var products = await db.Products
.IncludeMediaFor<Product, Media>(p => p.Cover)
.ToListAsync();Mutation helpers:
// Replace single-item collection
product.SetSingleMedia("cover", newMedia);
// Replace multi-item collection
product.SetCollectionMedia("gallery", newGalleryItems);This feature is fully opt-in — entities without [MediaCollection] properties work exactly as before.
Define conversions per collection with IHasMediaConversions:
public class Product : IHasMedia<Media>, IHasMediaConversions
{
// ...
public static IReadOnlyDictionary<string, List<ImageConversion>> GetMediaConversions() =>
new Dictionary<string, List<ImageConversion>>
{
["cover"] =
[
new ImageConversion("thumbnail", Width: 300, Height: null), // 300px wide, auto height
new ImageConversion("preview", Width: 800, Height: null), // 800px wide, auto height
new ImageConversion("og-image", Width: 1200, Height: 630, // Exact dimensions
Format: ImageOutputFormat.Jpeg, Quality: 90)
],
["gallery"] =
[
new ImageConversion("thumbnail", Width: 200, Height: 200) // Square thumbnail
]
};
}Conversions are stored in the Conversions dictionary:
var thumbnailUrl = product.GetFirstMediaUrl("thumbnail", "cover");
// Returns the thumbnail URL, or falls back to original if conversion doesn't existbuilder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
// Background processing (default: false — conversions run synchronously)
options.UseBackgroundProcessing = true;
// Channel capacity for background queues (backpressure control)
options.BackgroundQueueCapacity = 100;
// OpenTelemetry activity source name
options.ActivitySourceName = "MyApp.Media";
// Fallback base URL when HttpContext is unavailable
options.BaseUrl = "https://api.example.com";
// Default collection name for media without explicit collection
options.DefaultCollection = "default";
});AddMediaKit() returns an IMediaKitBuilder for fluent chaining:
builder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
options.UseBackgroundProcessing = true;
})
.AddS3Storage(config => // Storage provider (required)
{
config.BucketName = "my-media";
config.Region = "us-east-1";
config.CloudFrontDomain = "d123.cloudfront.net";
})
.AddImageSharp() // Image processor (optional)
.AddConversions( // Config-based conversions (optional)
builder.Configuration.GetSection("MediaConversions"));Storage is validated at startup — if no provider is registered, the app fails fast with a clear error message.
All storage providers implement IFileStorageService and support upload, download, delete, and signed URLs.
.AddLocalStorage(config =>
{
config.RootPath = "wwwroot/storage"; // Physical path on disk
config.BaseUrl = "/storage"; // URL prefix
config.JwtSecretKey = "..."; // Optional: enable signed download URLs
config.JwtIssuer = "MediaKit";
config.JwtAudience = "MediaKit";
}).AddS3Storage(config =>
{
config.BucketName = "my-media-bucket";
config.Region = "us-east-1";
config.PublicRead = true;
config.CloudFrontDomain = "d123.cloudfront.net"; // Optional CDN
config.CustomDomain = "media.example.com"; // Optional custom domain
})S3 uploads use Polly resilience pipelines with exponential backoff and jitter for transient error handling.
.AddSeaweedFSStorage(config =>
{
config.BucketName = "media";
config.S3Endpoint = "http://seaweedfs:8333";
config.FilerUrl = "http://seaweedfs:8888";
config.AccessKey = "admin";
config.SecretKey = "secret";
}).AddRustFSStorage(config =>
{
config.BucketName = "media";
config.Endpoint = "http://rustfs:9000";
config.AccessKey = "minioadmin";
config.SecretKey = "minioadmin";
config.Region = "us-east-1";
config.PublicRead = true;
config.CustomDomain = "cdn.example.com";
})RustFS automatically sets a public-read bucket policy (s3:GetObject for all principals) on first upload, so media URLs work without signed access. The policy is applied once and cached for the lifetime of the service.
.AddAzureBlobStorage(config =>
{
config.ConnectionString = "DefaultEndpointsProtocol=https;AccountName=...";
config.ContainerName = "media";
config.CreateContainerIfNotExists = true; // Default: true
config.PublicAccess = true; // Default: true — sets PublicAccessType.Blob on container
config.CustomDomain = "cdn.example.com"; // Optional custom domain
})When PublicAccess is true (the default), the container is created with PublicAccessType.Blob, allowing anonymous read access to blobs via their URLs. Set to false to require SAS signed URLs for all access.
Azure Blob Storage uses Polly resilience pipelines and generates SAS signed URLs for secure direct access.
Register your own IFileStorageService implementation without creating a NuGet package:
// Simple class registration
builder.Services.AddMediaKit<AppDbContext, Media>()
.AddCustomStorage<MyMinioStorageService>();
// With typed options
builder.Services.AddMediaKit<AppDbContext, Media>()
.AddCustomStorage<MyMinioStorageService, MyMinioOptions>(o =>
{
o.Endpoint = "http://localhost:9000";
o.BucketName = "media";
});
// Factory delegate for full control
builder.Services.AddMediaKit<AppDbContext, Media>()
.AddCustomStorage(sp => new MyMinioStorageService(
sp.GetRequiredService<IOptions<MyMinioOptions>>(),
sp.GetRequiredService<ILogger<MyMinioStorageService>>()
));Use AddStorage() to select the provider from appsettings.json:
.AddStorage(builder.Configuration){
"FileStorage": {
"Type": "s3",
"S3": {
"BucketName": "my-media",
"Region": "us-east-1",
"CloudFrontDomain": "d123.cloudfront.net"
}
}
}Supported types: s3, seaweedfs, rustfs, local. For Azure Blob Storage, install MediaKit.Storage.AzureBlobs and call .AddAzureBlobStorage() directly.
Register the built-in ImageSharp processor:
.AddImageSharp()This registers ImageSharpProcessor as IImageProcessor. It supports:
The IImageProcessor interface enables swapping to alternatives (SkiaSharp, Magick.NET):
public interface IImageProcessor
{
Task<IReadOnlyList<ImageConversionResult>> ProcessAsync(
Stream sourceStream, string fileName,
IReadOnlyList<ImageConversion> conversions,
CancellationToken ct = default);
}Define conversions in appsettings.json as a fallback when entities don't implement IHasMediaConversions:
{
"MediaConversions": {
"Conversions": {
"product": {
"cover": [
{ "Name": "thumbnail", "Width": 300, "Format": "WebP", "Quality": 80 },
{ "Name": "preview", "Width": 800, "Format": "WebP", "Quality": 85 }
]
}
}
}
}.AddConversions(builder.Configuration.GetSection("MediaConversions"))Type-safe conversions via IHasMediaConversions take priority over appsettings.json:
public class Product : IHasMediaConversions
{
public static IReadOnlyDictionary<string, List<ImageConversion>> GetMediaConversions() =>
new Dictionary<string, List<ImageConversion>>
{
["cover"] =
[
new ImageConversion("thumbnail", Width: 300, Height: null,
Format: ImageOutputFormat.WebP, Quality: 80)
]
};
}When enabled, image conversions and file deletions run in background hosted services using System.Threading.Channels with bounded capacity for backpressure:
options.UseBackgroundProcessing = true;
options.BackgroundQueueCapacity = 200;MediaProcessingHostedService processes them asynchronously.QueueAsync blocks until space is available, preventing memory exhaustion.When UseBackgroundProcessing = false (default), conversions run synchronously within the upload request.
Chain validation methods with a fluent API:
using MediaKit.AspNetCore.Validation;
// Individual validators
file.ValidateNotEmpty()
.ValidateIsImage()
.ValidateSize(maxSizeBytes: 10 * 1024 * 1024)
.ValidateMimeType(["image/jpeg", "image/png", "image/webp"])
.ValidateExtension([".jpg", ".png", ".webp"]);
await file.ValidateSignatureAsync(); // Magic byte verification
await file.ValidateImageDimensionsAsync( // Pixel dimension limits
maxWidth: 4000, maxHeight: 4000);
// Convenience: validate all common image constraints in one call
await file.ValidateImageUploadAsync(maxSizeBytes: 5 * 1024 * 1024);
// Batch validation
files.ValidateFiles(f => f.ValidateNotEmpty().ValidateIsImage().ValidateSize(10_000_000));All validators throw MediaValidationException with descriptive messages.
FileSignatureValidator inspects magic bytes to prevent MIME type spoofing:
using MediaKit.Validation;
var isValid = await FileSignatureValidator.ValidateAsync(stream, "image/jpeg");
var supported = FileSignatureValidator.GetSupportedMimeTypes();
// JPEG, PNG, GIF, WebP, BMP, ICO, SVG, PDF, MP4MediaService<TDbContext, TMedia> orchestrates uploads, conversions, and storage with OpenTelemetry tracing.
Upload a single file to an entity's collection:
var media = await mediaService.UploadAsync(product, file, "cover");
// For single-item collections, existing media is automatically replaced
// Old files are queued for background deletionUpload multiple files concurrently:
var results = await mediaService.UploadManyAsync(product, files, "gallery");
// Each file gets its own DI scope for safe concurrent uploadsDelete all existing media in a collection, then upload a new file:
var media = await mediaService.ReplaceCollectionAsync(product, file, "cover");Delete a single media item and its conversions:
var deleted = await mediaService.DeleteAsync(mediaId);Delete all media in a collection:
var count = await mediaService.DeleteCollectionAsync(product, "gallery");Rich extension methods on IHasMedia<TMedia> for convenient media access:
using MediaKit.Extensions;
// Get media by collection (ordered by SortOrder)
var covers = product.GetMedia("cover");
var galleryImages = product.GetMedia("gallery");
// First media item
var cover = product.GetFirstMedia("cover");
// Check existence
bool hasCover = product.HasMedia("cover");
int galleryCount = product.GetMediaCount("gallery");
// URLs — original or conversion
string? coverUrl = product.GetFirstMediaUrl("cover");
string? thumbUrl = product.GetFirstMediaUrl("thumbnail", "cover");
IEnumerable<string> urls = product.GetMediaUrls("gallery");
// Image dimensions (from CustomProperties)
var dims = product.GetFirstMediaDimensions("cover"); // (int Width, int Height)?
// Filter by MIME type
var images = product.GetImages("gallery");
var videos = product.GetVideos("gallery");
// Get all collection names on an entity
var collections = product.GetMediaCollectionNames();
// Check if a specific conversion exists
bool hasThumb = product.HasMediaConversion("thumbnail", "cover");
// Set/replace media in a single-item collection
product.SetSingleMedia("cover", newCoverMedia);
// Set/replace all media in a collection
product.SetCollectionMedia("gallery", newGalleryItems);MediaEntityConfiguration<TMedia> provides a portable configuration with JSON value conversions for Conversions and CustomProperties:
// In your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new MediaEntityConfiguration<Media>());
}This configures:
mediaId (max 26 chars)IX_Media_EntityType_EntityId_CollectionIX_Media_EntityType_CollectionIX_Media_EntityId_EntityTypeIX_Media_CreatedAt_Id_DescIX_Media_CreatedAt_Id_Ascusing MediaKit.EntityFrameworkCore;
// Eager load all media
var products = await db.Products
.IncludeMedia<Product, Media>()
.ToListAsync();
// Eager load specific collection
var products = await db.Products
.IncludeMedia<Product, Media>("cover")
.ToListAsync();
// Eager load multiple specific collections
var products = await db.Products
.IncludeMedia<Product, Media>("cover", "gallery")
.ToListAsync();
// Eager load via typed property expression (resolves from [MediaCollection] attribute)
var products = await db.Products
.IncludeMediaFor<Product, Media>(p => p.Cover)
.ToListAsync();
// Eager load multiple typed properties
var products = await db.Products
.IncludeMediaFor<Product, Media>(p => p.Cover, p => p.Gallery)
.ToListAsync();
// Explicit loading
await product.LoadMediaAsync<Product, Media>(db);
await product.LoadMediaAsync<Product, Media>(db, "cover");
await product.LoadMediaAsync<Product, Media>(db, ["cover", "gallery"]);
// Explicit loading via typed property expression
await product.LoadMediaForAsync<Product, Media>(db, p => p.Cover);
// Filter entities by media existence
var withCovers = await db.Products
.WhereHasMedia<Product, Media>("cover")
.ToListAsync();
// Filter by media count
var withGallery = await db.Products
.WhereHasMediaCount<Product, Media>(3, "gallery")
.ToListAsync();
// Filter by MIME type
var withImages = await db.Products
.WhereHasMediaType<Product, Media>("image/", "gallery")
.ToListAsync();
// Batch preload media for a list of entities
await products.PreloadMediaAsync<Product, Media>(db, "cover");Override MediaEntityConfiguration for database-specific column types:
public class AppMediaConfiguration : MediaEntityConfiguration<AppMedia>
{
public override void Configure(EntityTypeBuilder<AppMedia> builder)
{
base.Configure(builder);
// PostgreSQL jsonb columns
builder.Property(m => m.Conversions).HasColumnType("jsonb");
builder.Property(m => m.CustomProperties).HasColumnType("jsonb");
// App-specific columns
builder.Property(m => m.AltText).HasMaxLength(500);
builder.Property(m => m.Caption).HasMaxLength(1000);
// Global query filter (soft delete)
builder.HasQueryFilter(m => !m.IsDeleted);
}
}MediaKit.Dapper provides a lightweight, high-performance alternative to EF Core for media queries. It includes JSON type handlers, a generic repository, and query extensions that work seamlessly with typed collection properties.
Register Dapper with a standalone connection factory:
using MediaKit.Dapper.DependencyInjection;
builder.Services.AddMediaKit<Media>(options => { })
.AddDapper<Media>(sp => new NpgsqlConnection(connectionString))
.AddLocalStorage(config => { config.RootPath = "wwwroot/storage"; });Or register type handlers only (for direct Dapper usage without the repository):
builder.Services.AddMediaKit<Media>(options => { })
.AddDapperTypeHandlers();IMediaRepository<TMedia> provides full CRUD operations:
using MediaKit.Dapper.Repository;
public class ProductService(IMediaRepository<Media> mediaRepo)
{
public async Task<IReadOnlyList<Media>> GetProductMedia(string productId)
{
return await mediaRepo.GetByEntityAsync("product", productId);
}
public async Task<Media?> GetCover(string productId)
{
return await mediaRepo.GetFirstByEntityAsync("product", productId, "cover");
}
public async Task<int> GetGalleryCount(string productId)
{
return await mediaRepo.CountByEntityAsync("product", productId, "gallery");
}
public async Task<bool> DeleteGallery(string productId)
{
var count = await mediaRepo.DeleteByEntityAsync("product", productId, "gallery");
return count > 0;
}
}Extension methods on IDbConnection mirror the EF Core query extensions:
using MediaKit.Dapper.Extensions;
// Load all media for an entity
await connection.LoadMediaAsync<Product, Media>(product);
// Load a specific collection
await connection.LoadMediaAsync<Product, Media>(product, "cover");
// Load multiple collections
await connection.LoadMediaAsync<Product, Media>(product, ["cover", "gallery"]);
// Batch preload media for multiple entities
await connection.PreloadMediaAsync<Product, Media>(products, "cover");
// Check media existence and count
bool hasCover = await connection.HasMediaAsync<Product, Media>(product, "cover");
int count = await connection.GetMediaCountAsync<Product, Media>(product, "gallery");
// Find entities with specific media types
var ids = await connection.GetEntityIdsWithMediaTypeAsync(
"product", "image/", "gallery");Use typed property expressions to load collections — resolves the collection name from [MediaCollection] attributes:
// Load via property expression
await connection.LoadMediaForAsync<Product, Media>(product, p => p.Cover);
// Load multiple typed collections
await connection.LoadMediaForAsync<Product, Media>(
product,
[p => p.Cover, p => p.Gallery]);
// Typed properties work after Dapper loads the data
var coverUrl = product.Cover?.Url;
var galleryCount = product.Gallery.Count;Use Dapper alongside EF Core by extracting the connection from your DbContext:
builder.Services.AddMediaKit<AppDbContext, Media>(options => { })
.AddDapper<AppDbContext, Media>();This resolves AppDbContext from DI and extracts its IDbConnection via reflection — no compile-time EF Core dependency in the Dapper package.
IMediaUrlBuilder generates absolute URLs from relative paths, using HttpContext with a configurable fallback:
// Automatically registered — uses HttpContext.Request for scheme/host
// Falls back to MediaKitOptions.BaseUrl when HttpContext is unavailable (background services, CLI tools)
builder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
options.BaseUrl = "https://api.example.com";
});All MediaService operations emit OpenTelemetry activities with detailed tags:
options.ActivitySourceName = "MyApp.Media"; // Default: "MediaKit"Activities include:
MediaService.Upload — entity type, entity ID, collection, file size, upload duration, conversion countA complete working example is at samples/MediaKit.Sample/. It demonstrates:
Product entityIHasMedia, IHasMediaCollections, and IHasMediaConversions implementationsRun it:
cd samples/MediaKit.Sample
dotnet runThen test:
# Create a product
curl -X POST http://localhost:5000/api/products -F "name=Widget"
# Upload a cover image (single-item — replaces existing)
curl -X POST http://localhost:5000/api/products/1/cover -F "file=@photo.jpg"
# Upload gallery images (multi-item — appends)
curl -X POST http://localhost:5000/api/products/1/gallery \
-F "files=@img1.jpg" -F "files=@img2.jpg"
# List products with media URLs
curl http://localhost:5000/api/products
# Get product detail with full media info
curl http://localhost:5000/api/products/1
# Delete a media item
curl -X DELETE http://localhost:5000/api/media/{mediaId}MIT -- Copyright (c) 2026 MediaKit Contributors