Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/Assets/AssetStorageService.cs
T

139 lines
5.5 KiB
C#

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<string?> 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"
};
}