Add server-side tool to optimize images

This commit is contained in:
Pat Hartl 2026-06-15 21:45:14 -05:00
parent 1ec905420f
commit c1aca1572e
5 changed files with 342 additions and 0 deletions

View file

@ -12,6 +12,7 @@ using SixLabors.ImageSharp.Formats.Jpeg;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Extensions;
using LANCommander.Server.Services.Extensions;
using LANCommander.Server.Services.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp.PixelFormats;
@ -355,6 +356,164 @@ namespace LANCommander.Server.Services
}
}
public async Task<List<MediaOptimizationCandidate>> ScanOptimizationCandidatesAsync(MediaOptimizationOptions options)
{
var candidates = new List<MediaOptimizationCandidate>();
var allMedia = await Include(m => m.StorageLocation, m => m.Game).GetAsync();
foreach (var media in allMedia)
{
var path = GetMediaPath(media);
if (!File.Exists(path))
continue;
var mime = media.MimeType?.ToLowerInvariant();
var isPng = mime == "image/png";
var isJpeg = mime == "image/jpeg" || mime == "image/jpg";
if (!isPng && !isJpeg)
continue;
int width;
int height;
try
{
var info = await Image.IdentifyAsync(path);
width = info.Width;
height = info.Height;
}
catch
{
continue;
}
var oversized = options.Downscale && Math.Max(width, height) > options.MaxLongEdge;
var willConvert = isPng && options.ConvertPngToJpeg && media.Type != MediaType.Logo && media.Type != MediaType.Icon;
var willRecompress = isJpeg && options.RecompressJpeg;
if (!willConvert && !oversized && !willRecompress)
continue;
var actions = new List<string>();
if (willConvert)
actions.Add("PNG → JPEG");
if (oversized)
actions.Add($"Downscale to {options.MaxLongEdge}px");
if (willRecompress)
actions.Add("Recompress");
if (options.StripMetadata)
actions.Add("Strip metadata");
candidates.Add(new MediaOptimizationCandidate
{
Id = media.Id,
GameTitle = media.Game?.Title ?? media.Name ?? media.Type.ToString(),
Type = media.Type,
MimeType = media.MimeType,
Width = width,
Height = height,
Size = new FileInfo(path).Length,
PlannedAction = string.Join(", ", actions),
});
}
return candidates.OrderByDescending(c => c.Size).ToList();
}
public async Task<MediaOptimizationResult> OptimizeMediaAsync(Media media, MediaOptimizationOptions options)
{
var result = new MediaOptimizationResult();
if (media.StorageLocation == null)
media.StorageLocation = await storageLocationService.GetAsync(media.StorageLocationId);
var path = GetMediaPath(media);
if (!File.Exists(path))
return result;
var mime = media.MimeType?.ToLowerInvariant();
var isPng = mime == "image/png";
var isJpeg = mime == "image/jpeg" || mime == "image/jpg";
if (!isPng && !isJpeg)
return result;
result.BeforeBytes = new FileInfo(path).Length;
try
{
using (var image = await Image.LoadAsync<Rgba32>(path))
{
var changed = false;
if (options.Downscale && Math.Max(image.Width, image.Height) > options.MaxLongEdge)
{
image.Mutate(ctx => ctx.Resize(new ResizeOptions
{
Mode = ResizeMode.Max,
Size = new Size(options.MaxLongEdge, options.MaxLongEdge),
Sampler = KnownResamplers.Bicubic,
}));
changed = true;
}
var transparent = isPng && HasTransparentPixels(image);
var convertToJpeg = isPng && options.ConvertPngToJpeg && !transparent && media.Type != MediaType.Logo && media.Type != MediaType.Icon;
var hasMetadata = image.Metadata.ExifProfile != null
|| image.Metadata.IccProfile != null
|| image.Metadata.XmpProfile != null;
if (options.StripMetadata && hasMetadata)
{
image.Metadata.ExifProfile = null;
image.Metadata.IccProfile = null;
image.Metadata.XmpProfile = null;
changed = true;
}
var reencodeJpeg = isJpeg && (options.RecompressJpeg || changed);
if (!convertToJpeg && !reencodeJpeg && !(isPng && changed))
return result;
var tempPath = path + ".optimizing";
if (convertToJpeg || isJpeg)
await image.SaveAsJpegAsync(tempPath, new JpegEncoder { Quality = options.JpegQuality });
else
await image.SaveAsPngAsync(tempPath);
File.Delete(path);
File.Move(tempPath, path);
if (convertToJpeg)
media.MimeType = MediaTypeNames.Image.Jpeg;
}
media.Crc32 = await SDK.Services.MediaClient.CalculateChecksumAsync(path);
await GenerateThumbnailAsync(media);
await UpdateAsync(media);
result.AfterBytes = new FileInfo(path).Length;
result.Changed = true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Could not optimize media with ID {MediaId}", media.Id);
}
return result;
}
public async Task<StorageLocation> GetDefaultStorageLocationAsync()
{
var defaultStorageLocation = await storageLocationService.FirstOrDefaultAsync(l => l.Type == StorageLocationType.Media && l.Default);

View file

@ -0,0 +1,34 @@
using System;
using LANCommander.SDK.Enums;
namespace LANCommander.Server.Services.Models
{
public class MediaOptimizationOptions
{
public bool ConvertPngToJpeg { get; set; } = true;
public int JpegQuality { get; set; } = 90;
public bool Downscale { get; set; } = true;
public int MaxLongEdge { get; set; } = 3840;
public bool RecompressJpeg { get; set; } = false;
public bool StripMetadata { get; set; } = true;
}
public class MediaOptimizationCandidate
{
public Guid Id { get; set; }
public string GameTitle { get; set; }
public MediaType Type { get; set; }
public string MimeType { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public long Size { get; set; }
public string PlannedAction { get; set; }
}
public class MediaOptimizationResult
{
public bool Changed { get; set; }
public long BeforeBytes { get; set; }
public long AfterBytes { get; set; }
}
}

View file

@ -0,0 +1,46 @@
using LANCommander.Server.Services;
using LANCommander.Server.Services.Models;
namespace LANCommander.Server.Jobs.Background
{
public class OptimizeImagesJob : BaseBackgroundJob
{
private readonly MediaService MediaService;
private readonly ILogger<OptimizeImagesJob> Logger;
public OptimizeImagesJob(
ILogger<OptimizeImagesJob> logger,
MediaService mediaService) : base(logger)
{
MediaService = mediaService;
Logger = logger;
}
public override Task ExecuteAsync() => ExecuteAsync(new MediaOptimizationOptions());
public async Task ExecuteAsync(MediaOptimizationOptions options)
{
var allMedia = await MediaService.Include(m => m.StorageLocation).GetAsync();
long totalBefore = 0;
long totalAfter = 0;
var optimized = 0;
foreach (var media in allMedia)
{
var result = await MediaService.OptimizeMediaAsync(media, options);
if (!result.Changed)
continue;
optimized++;
totalBefore += result.BeforeBytes;
totalAfter += result.AfterBytes;
}
Logger?.LogInformation(
"Image optimization complete. Optimized {Count} media, reclaimed {Saved} bytes ({Before} -> {After})",
optimized, totalBefore - totalAfter, totalBefore, totalAfter);
}
}
}

View file

@ -71,6 +71,12 @@
<h3>Regenerate Thumbnails</h3>
<p>Clear out the existing thumbnail cache and generate replacements.</p>
<Button Type="ButtonType.Primary" OnClick="RegenerateThumbnails" Loading="RegeneratingThumbnails">Regenerate</Button>
<Divider />
<h3>Optimize Images</h3>
<p>Convert opaque PNG art to JPEG, downscale oversized media, and strip metadata to reclaim disk space while preserving quality.</p>
<a href="/Settings/Tools/OptimizeImages" class="ant-btn ant-btn-primary">Optimize Images</a>
</PageContent>
@code {

View file

@ -0,0 +1,97 @@
@page "/Settings/Tools/OptimizeImages"
@using Hangfire
@using LANCommander.Server.Jobs.Background
@using LANCommander.Server.Services.Models
@inject MediaService MediaService
@inject IMessageService MessageService
@inject ILogger<OptimizeImages> Logger
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
<PageHeader Title="Optimize Images" />
<PageContent>
<p>
Reclaim disk space while preserving art quality. Opaque PNG art (backgrounds, covers, screenshots) is converted to high-quality JPEG, oversized images are downscaled, and metadata is stripped. Images with transparency (icons, logos) are never converted. Optimization replaces the original files in place and runs in the background.
</p>
<Form Layout="@FormLayout.Vertical" Model="@Options">
<FormItem Label="Convert opaque PNG to JPEG">
<Switch @bind-Value="@Options.ConvertPngToJpeg" />
</FormItem>
<FormItem Label="Downscale oversized images">
<Switch @bind-Value="@Options.Downscale" />
</FormItem>
<FormItem Label="Recompress existing JPEGs (lossy)">
<Switch @bind-Value="@Options.RecompressJpeg" />
</FormItem>
<FormItem Label="Strip metadata (EXIF/ICC/XMP)">
<Switch @bind-Value="@Options.StripMetadata" />
</FormItem>
<FormItem Label="JPEG quality">
<AntDesign.InputNumber @bind-Value="@Options.JpegQuality" Min="1" Max="100" />
</FormItem>
<FormItem Label="Max long edge (px)">
<AntDesign.InputNumber @bind-Value="@Options.MaxLongEdge" Min="256" />
</FormItem>
</Form>
<Space Align="SpaceAlign.Center" Style="margin-bottom: 16px">
<SpaceItem>
<Button Type="ButtonType.Primary" OnClick="Scan" Loading="@Loading">Scan</Button>
</SpaceItem>
<SpaceItem>
<Popconfirm OnConfirm="Optimize" Title="Optimize all listed images? Originals will be replaced in place." Disabled="@(Candidates == null || !Candidates.Any())">
<Button Disabled="@(Candidates == null || !Candidates.Any())">Optimize Listed</Button>
</Popconfirm>
</SpaceItem>
</Space>
</PageContent>
<Table TItem="MediaOptimizationCandidate" DataSource="@Candidates" Loading="@Loading" PageSize="25" Responsive>
<PropertyColumn Property="c => c.GameTitle" Title="Game" />
<PropertyColumn Property="c => c.Type" Title="Type" />
<PropertyColumn Property="c => c.MimeType" Title="Format" />
<Column TData="string" Title="Dimensions">
@context.Width x @context.Height
</Column>
<PropertyColumn Property="c => c.Size" Title="Size" Sortable>
<ByteSize Value="context.Size" />
</PropertyColumn>
<PropertyColumn Property="c => c.PlannedAction" Title="Planned Action" />
</Table>
@code {
MediaOptimizationOptions Options = new();
ICollection<MediaOptimizationCandidate> Candidates;
bool Loading = false;
async Task Scan()
{
Loading = true;
await Task.Yield();
await InvokeAsync(StateHasChanged);
try
{
Candidates = await MediaService.ScanOptimizationCandidatesAsync(Options);
}
catch (Exception ex)
{
Logger.LogError(ex, "Could not scan media for optimization candidates");
MessageService.Error("Could not scan media.");
}
Loading = false;
await Task.Yield();
await InvokeAsync(StateHasChanged);
}
void Optimize()
{
BackgroundJob.Enqueue<OptimizeImagesJob>(x => x.ExecuteAsync(Options));
MessageService.Success("Optimization is running in the background.");
}
}