refactor: repurpose MediaService as shared media URL builder

This commit is contained in:
2026-08-17 19:30:59 +02:00
parent e6a8799965
commit 9f5d9c0c11
11 changed files with 67 additions and 165 deletions
@@ -2,14 +2,13 @@
@using Butter.Dtos.Album
@using Butter.Dtos.Asset
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
@inject NavigationManager navigationManager
<PageTitle>@(album?.Name ?? "Album")</PageTitle>
@@ -157,7 +156,7 @@
style="aspect-ratio:@GetAspectRatio(preview)"
@onclick="() => HandleTileClick(index, assetId)">
@if (preview == null || preview.HasThumbnail) {
<img src="@ThumbUrl(assetId)"
<img src="@mediaService.ThumbUrl(assetId)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
@@ -249,10 +248,6 @@
HashSet<Guid> selectedImageIds = [];
Guid? selectedAssetId;
int selectedIndex = -1;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string viewMode = "masonry";
bool _masonrySetup;
@@ -293,7 +288,6 @@
protected override async Task OnParametersSetAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadAlbum();
}
@@ -309,9 +303,6 @@
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
}
async Task LoadAlbum() {
@@ -504,8 +495,6 @@
return "4 / 3";
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync()
{
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
@@ -3,13 +3,12 @@
@using Butter.Dtos.Asset
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization
@inject PersonService personService
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
@inject NavigationManager navigationManager
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
@@ -40,7 +39,7 @@
var cy = person.ProfileCropY ?? 50;
var z = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-avatar-clip">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
<img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-avatar-img"
style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" />
</div>
@@ -317,8 +316,6 @@
Func<Task>? _pendingDeleteAction;
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string viewMode = "masonry";
bool showAlbumAssigner;
List<AlbumPreviewDto>? unassignedAlbums;
@@ -341,8 +338,6 @@
int albumLoadVersion;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
List<Guid>? allAssetIds => person?.Albums?
.SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1))
.Where(id => id != Guid.Empty)
@@ -350,15 +345,11 @@
.ToList();
protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadPerson();
}
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
}
async Task LoadPerson() {
@@ -372,7 +363,7 @@
.Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next())
.Take(3)
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
.Select(a => mediaService.ThumbUrl(a.CoverAssetId!.Value))
.ToList();
}
@@ -522,6 +513,4 @@
if (_pendingDeleteAction != null)
await _pendingDeleteAction();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
}
+3 -14
View File
@@ -1,13 +1,12 @@
@page "/"
@using Butter.Dtos.Asset
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
<PageTitle>Home</PageTitle>
@@ -36,7 +35,7 @@
style="aspect-ratio:@GetAspectRatio(asset)"
@onclick="() => OpenPreview(asset, capturedIndex)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)"
<img src="@mediaService.ThumbUrl(asset.Id)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
@@ -136,18 +135,10 @@
ElementReference topSentinelRef;
DotNetObjectReference<Home>? dotNetRef;
Guid _randomSeed;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
StateHasChanged();
};
token = loginService.AuthInfo?.Token ?? string.Empty;
loginService.AuthInfoChanged += (_, _) => StateHasChanged();
await LoadFirstPage();
}
@@ -277,8 +268,6 @@
return "4 / 3";
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
if (dotNetRef != null) {
@@ -2,14 +2,13 @@
@using Butter.Dtos.Asset
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IDisposable
@inject AssetService assetService
@inject AlbumService albumService
@inject PersonService personService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
@if (Embedded) {
<div class="picker-embedded">
@@ -86,7 +85,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -137,7 +136,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -254,7 +253,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -305,7 +304,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -429,7 +428,6 @@
string currentPath = "";
HashSet<Guid> selectedIds = [];
string searchQuery = string.Empty;
string token = string.Empty;
int currentPage;
bool isLoading;
bool hasMore = true;
@@ -455,15 +453,12 @@
const int PageSize = 150;
bool isAdminOrCurator => loginService.IsAdminOrCurator;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() {
if (Embedded) await InitializeAsync();
}
protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
if (!Embedded && Show && !_initializing) {
_initializing = true;
await InitializeAsync();
@@ -613,8 +608,6 @@
_personSearchTimer?.Dispose();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
static string TruncateName(string name, int maxLen = 40) {
if (name.Length <= maxLen) return name;
var lastSlash = name.LastIndexOf('/');
@@ -1,15 +1,14 @@
@using Butter.Dtos.Album
@using Butter.Types
@using Microsoft.Extensions.Options
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService
@inject MediaService mediaService
<div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()">
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
@if (Album.CoverAssetId.HasValue) {
<img src="@ThumbUrl(Album.CoverAssetId.Value)"
<img src="@mediaService.ThumbUrl(Album.CoverAssetId.Value)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="album-card-fallback" style="display:none">
@@ -54,12 +53,6 @@
[Parameter]
public string ViewMode { get; set; } = "masonry";
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string token => loginService.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
string GetCardClass() {
var classes = new List<string> { "album-card" };
if (SelectionMode) {
@@ -1,9 +1,6 @@
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@using System.Linq
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
<ModalFrame Title="Select Cover Image" Show="Show" Size="lg" OnClose="OnCancel">
<Body>
@@ -15,7 +12,7 @@
<div class="picker-tile @(SelectedAssetId == id ? "selected" : "")"
@key="id"
@onclick="() => SelectAsset(id)">
<img src="@ThumbUrl(id)"
<img src="@mediaService.ThumbUrl(id)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
@@ -47,15 +44,6 @@
[Parameter] public EventCallback<Guid?> OnSelected { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override void OnParametersSet() {
token = loginService.AuthInfo?.Token ?? string.Empty;
}
void SelectAsset(Guid id) {
SelectedAssetId = id;
}
@@ -68,6 +56,4 @@
async Task OnCancel() {
await OnClosed.InvokeAsync();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
}
@@ -1,12 +1,11 @@
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization
@implements IAsyncDisposable
@inject PersonService personService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
@inject IJSRuntime jsRuntime
@if (isLoading) {
@@ -34,7 +33,7 @@
var cy1 = person.ProfileCropY ?? 50;
var zoom1 = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-card-img-wrap">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
<img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-card-img"
style="width: calc(100% / @zoom1.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx1.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy1.ToString(CultureInfo.InvariantCulture) * 1%))"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
@@ -92,14 +91,6 @@
string? _prevSearch;
string? _prevSortBy;
bool _prevSortAsc;
string _token = string.Empty;
string _apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(_token) ? "" : $"?token={_token}";
protected override void OnInitialized() {
loginService.AuthInfoChanged += OnAuthInfoChanged;
_token = loginService.AuthInfo?.Token ?? string.Empty;
}
protected override async Task OnParametersSetAsync() {
bool filterChanged = _prevLoadVersion != LoadVersion
@@ -183,8 +174,6 @@
}
}
string ThumbUrl(Guid id) => $"{_apiBase}/api/media/thumb/{id}{TokenParam}";
string GetCardClass(PersonPreviewDto person) {
var classes = new List<string> { "cosplayer-card" };
if (SelectionMode) {
@@ -198,10 +187,6 @@
return string.Join(" ", classes);
}
void OnAuthInfoChanged(object? _, AuthInfo? info) {
_token = info?.Token ?? string.Empty;
}
async Task DisposeObserver() {
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose");
@@ -211,7 +196,6 @@
}
public async ValueTask DisposeAsync() {
loginService.AuthInfoChanged -= OnAuthInfoChanged;
await DisposeObserver();
}
@@ -1,8 +1,5 @@
@using Microsoft.Extensions.Options
@inject IOptions<ServiceOptions> Options
@inject LoginService LoginService
@inject IJSRuntime JSRuntime
@inject MediaService mediaService
@if (Show && AssetId != Guid.Empty) {
<div class="preview-overlay" @onkeydown="OnKeyDown" tabindex="0" @onclick="OnCloseCallback">
@@ -18,10 +15,10 @@
<div class="preview-image-wrapper">
@if (HasPreview) {
<img class="preview-img" src="@PreviewUrl" alt=""
onerror="this.onerror=null;this.src='@ThumbUrl'" />
<img class="preview-img" src="@mediaService.PreviewUrl(AssetId)" alt=""
onerror="this.onerror=null;this.src='@mediaService.ThumbUrl(AssetId)'" />
} else {
<img class="preview-img" src="@ThumbUrl" alt="" />
<img class="preview-img" src="@mediaService.ThumbUrl(AssetId)" alt="" />
}
<div class="preview-info">
<div class="preview-info-text">
@@ -32,7 +29,7 @@
<span class="preview-info-filename">@Filename</span>
}
<div class="preview-info-actions">
<a class="preview-action-btn" href="@OriginalUrl" target="_blank"
<a class="preview-action-btn" href="@mediaService.OriginalUrl(AssetId)" target="_blank"
title="View full size" @onclick:stopPropagation="true">
<i class="bi bi-arrows-fullscreen"></i>
</a>
@@ -59,18 +56,6 @@
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
string token = string.Empty;
string apiBase => Options.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ThumbUrl => $"{apiBase}/api/media/thumb/{AssetId}{TokenParam}";
string PreviewUrl => $"{apiBase}/api/media/preview/{AssetId}{TokenParam}";
string OriginalUrl => $"{apiBase}/api/media/original/{AssetId}{TokenParam}";
protected override void OnInitialized() {
LoginService.AuthInfoChanged += (_, info) => { token = info?.Token ?? string.Empty; };
token = LoginService.AuthInfo?.Token ?? string.Empty;
}
async Task OnCloseCallback() {
await OnClose.InvokeAsync();
}
@@ -90,6 +75,6 @@
}
async Task Download() {
await JSRuntime.InvokeVoidAsync("masonryObserver.downloadFile", OriginalUrl);
await JSRuntime.InvokeVoidAsync("masonryObserver.downloadFile", mediaService.OriginalUrl(AssetId));
}
}
@@ -1,7 +1,5 @@
@using Microsoft.Extensions.Options
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService
@inject MediaService mediaService
@if (Show && AssetId.HasValue) {
<div class="modal-overlay" style="background: rgba(0, 0, 0, 0.85)" @onclick="OnCancel">
@@ -72,11 +70,6 @@
/// </summary>
[Parameter] public EventCallback OnClosed { get; set; }
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string token => loginService.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ImageUrl => $"{apiBase}/api/media/thumb/{AssetId!.Value}{TokenParam}";
protected override void OnInitialized() {
_instanceId = ++_counter;
}
@@ -93,7 +86,7 @@
if (_pendingInit) {
_pendingInit = false;
await jsRuntime.InvokeVoidAsync("profileCropper.initialize",
ContainerId, ImageUrl, CropX ?? 50, CropY ?? 50, CropZoom ?? 0.6f);
ContainerId, mediaService.ThumbUrl(AssetId!.Value), CropX ?? 50, CropY ?? 50, CropZoom ?? 0.6f);
}
}
@@ -1,6 +1,5 @@
@using Butter.Dtos.Album
@using Butter.Dtos.Person
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@namespace MilkStream.Client.Components.Shared
@@ -8,8 +7,7 @@
@inject PersonService PersonSvc
@inject AlbumService AlbumSvc
@inject NavigationManager Nav
@inject IOptions<ServiceOptions> ServiceOptions
@inject LoginService LoginSvc
@inject MediaService mediaService
<div class="search-dropdown-container position-relative">
<div class="d-flex">
@@ -67,7 +65,7 @@
@onmouseenter="() => OnItemHover(idx)">
@if (item.ImageId.HasValue)
{
<img class="search-dropdown-thumb" src="@ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
<img class="search-dropdown-thumb" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
}
else
{
@@ -85,7 +83,7 @@
@onmouseenter="() => OnItemHover(idx)">
@if (item.ImageId.HasValue)
{
<img class="search-dropdown-thumb-rounded" src="@ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
<img class="search-dropdown-thumb-rounded" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
}
else
{
@@ -117,12 +115,6 @@
int _selectedIndex = -1;
CancellationTokenSource? _blurCts;
string ApiBase => ServiceOptions.Value.BaseUrl.TrimEnd('/');
string Token => LoginSvc.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(Token) ? "" : $"?token={Token}";
string ThumbUrl(Guid id) => $"{ApiBase}/api/media/thumb/{id}{TokenParam}";
async Task OnInput(ChangeEventArgs e)
{
_query = e.Value?.ToString() ?? string.Empty;
+30 -21
View File
@@ -3,28 +3,37 @@ using Microsoft.Extensions.Options;
namespace MilkStream.Client.Services;
/// <summary>
/// Provides access to media files (original, thumbnails, previews) via the API.
/// </summary>
public sealed class MediaService : AuthServiceBase {
/// <inheritdoc />
protected override HttpClient Client { get; init; }
/// <inheritdoc />
protected override ILogger Logger { get; init; }
/// <summary>
/// Initialises a new instance of the <see cref="MediaService"/> class.
/// Builds media URLs (original, thumbnail, preview) authenticated via a token query parameter.
/// Media is served by <c>MediaController</c> through <c>&lt;img&gt;</c>-accessible URLs, so the
/// JWT is passed as a <c>token</c> query parameter rather than an Authorization header.
/// </summary>
/// <param name="options">Service configuration options.</param>
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
/// <param name="loginService">The login service for authentication.</param>
/// <param name="logger">Logger instance.</param>
public MediaService(
IOptions<ServiceOptions> options,
IHttpClientFactory httpClientFactory,
LoginService loginService,
ILogger<MediaService> logger
) : base(options, httpClientFactory, loginService, logger) {
Client = base.Client;
Logger = logger;
/// <param name="loginService">The login service providing the current auth token.</param>
public sealed class MediaService(IOptions<ServiceOptions> options, LoginService loginService) {
string BaseUrl => options.Value.BaseUrl.TrimEnd('/');
string TokenParam {
get {
var token = loginService.AuthInfo?.Token;
return string.IsNullOrEmpty(token) ? "" : $"?token={token}";
}
}
/// <summary>
/// Gets the thumbnail URL for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
public string ThumbUrl(Guid id) => $"{BaseUrl}/api/media/thumb/{id}{TokenParam}";
/// <summary>
/// Gets the preview URL for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
public string PreviewUrl(Guid id) => $"{BaseUrl}/api/media/preview/{id}{TokenParam}";
/// <summary>
/// Gets the original image URL for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
public string OriginalUrl(Guid id) => $"{BaseUrl}/api/media/original/{id}{TokenParam}";
}