using SkiaSharp;
namespace Sakayaki.Services;
public sealed class ThumbnailService
{
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"
};
private const int MinSize = 250;
private const int JpegQuality = 82;
private readonly string? _rootPath;
private readonly string? _thumbnailRoot;
private readonly ILogger<ThumbnailService> _logger;
public ThumbnailService(IConfiguration configuration, ILogger<ThumbnailService> logger)
{
_rootPath = configuration["Fanbox:RootPath"];
_thumbnailRoot = configuration["Fanbox:ThumbnailRoot"];
_logger = logger;
TryEnsureRoot();
}
public string? GetThumbnailPath(Guid folderId, string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var safeName = Path.GetFileName(fileName);
if (string.IsNullOrWhiteSpace(safeName))
{
return null;
}
var thumbName = Path.ChangeExtension(safeName, ".jpg");
if (string.IsNullOrWhiteSpace(_thumbnailRoot))
{
return null;
}
var dir = Path.Combine(_thumbnailRoot, folderId.ToString("N"));
return Path.Combine(dir, thumbName);
}
public string? GetOriginalPath(string folderName, string fileName)
{
if (string.IsNullOrWhiteSpace(_rootPath))
{
return null;
}
var safeName = Path.GetFileName(fileName);
if (string.IsNullOrWhiteSpace(safeName))
{
return null;
}
return Path.Combine(_rootPath, folderName, safeName);
}
public async Task<int> EnsureFolderThumbnailsAsync(Guid folderId, string folderName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_rootPath))
{
return 0;
}
var folderPath = Path.Combine(_rootPath, folderName);
if (!Directory.Exists(folderPath))
{
return 0;
}
var files = Directory.GetFiles(folderPath)
.Where(path => ImageExtensions.Contains(Path.GetExtension(path)))
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
if (files.Length == 0)
{
return 0;
}
var created = 0;
var maxConcurrency = Math.Max(1, Environment.ProcessorCount);
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
var tasks = new List<Task>(files.Length);
foreach (var fileName in files)
{
cancellationToken.ThrowIfCancellationRequested();
var thumbPath = GetThumbnailPath(folderId, fileName);
if (thumbPath is null || File.Exists(thumbPath))
{
continue;
}
await gate.WaitAsync(cancellationToken);
tasks.Add(Task.Run(async () =>
{
try
{
if (await EnsureThumbnailAsync(folderId, folderName, fileName, cancellationToken))
{
Interlocked.Increment(ref created);
}
}
finally
{
gate.Release();
}
}, cancellationToken));
}
await Task.WhenAll(tasks);
return created;
}
public Task<bool> EnsureThumbnailAsync(Guid folderId, string folderName, string fileName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_rootPath))
{
return Task.FromResult(false);
}
var originalPath = GetOriginalPath(folderName, fileName);
if (string.IsNullOrWhiteSpace(originalPath) || !File.Exists(originalPath))
{
return Task.FromResult(false);
}
var thumbPath = GetThumbnailPath(folderId, fileName);
if (string.IsNullOrWhiteSpace(thumbPath))
{
return Task.FromResult(false);
}
if (File.Exists(thumbPath))
{
return Task.FromResult(true);
}
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(thumbPath) ?? _thumbnailRoot);
return TryCreateThumbnail(originalPath, thumbPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create thumbnail for {FileName}", fileName);
return false;
}
}, cancellationToken);
}
private static bool TryCreateThumbnail(string sourcePath, string thumbPath)
{
using var bitmap = SKBitmap.Decode(sourcePath);
if (bitmap is null)
{
return false;
}
var minSide = Math.Min(bitmap.Width, bitmap.Height);
var scale = minSide > 0 ? MinSize / (float)minSide : 1f;
var targetWidth = Math.Max(1, (int)Math.Round(bitmap.Width * scale));
var targetHeight = Math.Max(1, (int)Math.Round(bitmap.Height * scale));
SKBitmap? resized = null;
var target = bitmap;
if (scale < 1f)
{
resized = bitmap.Resize(new SKImageInfo(targetWidth, targetHeight), SKFilterQuality.Medium);
if (resized is null)
{
return false;
}
target = resized;
}
using var image = SKImage.FromBitmap(target);
using var data = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality);
using var stream = File.Open(thumbPath, FileMode.Create, FileAccess.Write, FileShare.None);
data.SaveTo(stream);
resized?.Dispose();
return true;
}
private void TryEnsureRoot()
{
try
{
if (string.IsNullOrWhiteSpace(_thumbnailRoot))
{
return;
}
Directory.CreateDirectory(_thumbnailRoot);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to ensure thumbnail root at {ThumbnailRoot}", _thumbnailRoot);
}
}
}