Newer
Older
Sakayaki / Services / ThumbnailService.cs
@fabre fabre 9 hours ago 6 KB 超级缩略图
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);
        }
    }

}