diff --git a/LANCommander.Server.Services/MediaService.cs b/LANCommander.Server.Services/MediaService.cs index 0616a6f8..5ab0968d 100644 --- a/LANCommander.Server.Services/MediaService.cs +++ b/LANCommander.Server.Services/MediaService.cs @@ -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> ScanOptimizationCandidatesAsync(MediaOptimizationOptions options) + { + var candidates = new List(); + 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(); + + 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 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(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 GetDefaultStorageLocationAsync() { var defaultStorageLocation = await storageLocationService.FirstOrDefaultAsync(l => l.Type == StorageLocationType.Media && l.Default); diff --git a/LANCommander.Server.Services/Models/MediaOptimizationOptions.cs b/LANCommander.Server.Services/Models/MediaOptimizationOptions.cs new file mode 100644 index 00000000..8c596e08 --- /dev/null +++ b/LANCommander.Server.Services/Models/MediaOptimizationOptions.cs @@ -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; } + } +} diff --git a/LANCommander.Server/Jobs/Background/OptimizeImages.cs b/LANCommander.Server/Jobs/Background/OptimizeImages.cs new file mode 100644 index 00000000..8357333e --- /dev/null +++ b/LANCommander.Server/Jobs/Background/OptimizeImages.cs @@ -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 Logger; + + public OptimizeImagesJob( + ILogger 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); + } + } +} diff --git a/LANCommander.Server/UI/Pages/Settings/Tools/Index.razor b/LANCommander.Server/UI/Pages/Settings/Tools/Index.razor index 1c35b2e6..5f8eb7cc 100644 --- a/LANCommander.Server/UI/Pages/Settings/Tools/Index.razor +++ b/LANCommander.Server/UI/Pages/Settings/Tools/Index.razor @@ -71,6 +71,12 @@

Regenerate Thumbnails

Clear out the existing thumbnail cache and generate replacements.

+ + + +

Optimize Images

+

Convert opaque PNG art to JPEG, downscale oversized media, and strip metadata to reclaim disk space while preserving quality.

+ Optimize Images @code { diff --git a/LANCommander.Server/UI/Pages/Settings/Tools/OptimizeImages.razor b/LANCommander.Server/UI/Pages/Settings/Tools/OptimizeImages.razor new file mode 100644 index 00000000..fb9d245f --- /dev/null +++ b/LANCommander.Server/UI/Pages/Settings/Tools/OptimizeImages.razor @@ -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 Logger +@attribute [Authorize(Roles = RoleService.AdministratorRoleName)] + + + + +

+ 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. +

+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + @context.Width x @context.Height + + + + + +
+ +@code { + MediaOptimizationOptions Options = new(); + ICollection 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(x => x.ExecuteAsync(Options)); + + MessageService.Success("Optimization is running in the background."); + } +}