using System.Net.Http.Headers; using System.Security.Cryptography; using Jellyfin.Plugin.Multilang.Data; namespace Jellyfin.Plugin.Multilang.Services.Assets; public sealed class AssetStorageService { private const long MaxAssetBytes = 50L * 1024L * 1024L; private readonly IHttpClientFactory _httpClientFactory; private readonly TranslationStore _store; public AssetStorageService(IHttpClientFactory httpClientFactory, TranslationStore store) { _httpClientFactory = httpClientFactory; _store = store; } public async Task StoreAsync( string itemId, string lang, string kind, string sourceUrl, bool localStorage, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(sourceUrl)) return null; if (!localStorage) return sourceUrl; var relative = BuildRelativePath(itemId, lang, kind, sourceUrl); var expectedUrl = TranslationStore.LocalAssetUrlPrefix + relative.Replace('\\', '/'); if (_store.GetAssetPath(itemId, lang, kind) is { } existing && existing.Equals(expectedUrl, StringComparison.OrdinalIgnoreCase) && _store.TryNormalizeLocalAssetPath(existing, out var existingPath) && File.Exists(existingPath)) { return existing; } var destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative)); var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar; if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Resolved asset path escaped the Multilang assets directory."); Directory.CreateDirectory(Path.GetDirectoryName(destination) ?? _store.AssetsDirectory); await DownloadAsync(sourceUrl, destination, cancellationToken).ConfigureAwait(false); return expectedUrl; } public bool TryResolveLocalAsset(string relativePath, out string fullPath, out string contentType) { fullPath = string.Empty; contentType = "application/octet-stream"; var clean = (relativePath ?? string.Empty).TrimStart('/', '\\'); if (clean.Length == 0) return false; var candidate = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, clean)); var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar; if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate)) return false; fullPath = candidate; contentType = ContentTypeFor(Path.GetExtension(candidate)); return true; } private async Task DownloadAsync(string sourceUrl, string destination, CancellationToken cancellationToken) { var http = _httpClientFactory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Get, sourceUrl); using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); if (response.Content.Headers.ContentLength is > MaxAssetBytes) throw new InvalidOperationException("Provider asset is larger than the allowed download size."); var temp = destination + ".tmp"; var total = 0L; await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) await using (var output = File.Create(temp)) { var buffer = new byte[128 * 1024]; while (true) { var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (read == 0) break; total += read; if (total > MaxAssetBytes) throw new InvalidOperationException("Provider asset is larger than the allowed download size."); await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); } } File.Move(temp, destination, overwrite: true); } private static string BuildRelativePath(string itemId, string lang, string kind, string sourceUrl) { var extension = ExtensionFromUrl(sourceUrl); var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sourceUrl))).ToLowerInvariant()[..16]; return Path.Combine(SafeSegment(itemId), SafeSegment(lang), SafeSegment(kind) + "-" + hash + extension); } private static string SafeSegment(string value) { var chars = value .Select(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_') .ToArray(); var result = new string(chars).Trim('_'); return result.Length == 0 ? "unknown" : result; } private static string ExtensionFromUrl(string sourceUrl) { if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out var uri)) return ".bin"; var ext = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant(); return ext is ".jpg" or ".jpeg" or ".png" or ".webp" ? ext : ".bin"; } private static string ContentTypeFor(string extension) => extension.ToLowerInvariant() switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".webp" => "image/webp", ".gif" => "image/gif", _ => MediaTypeHeaderValue.Parse("application/octet-stream").MediaType ?? "application/octet-stream" }; }