Add backup export and local artwork storage
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user