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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Multilang.Configuration;
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Multilang.Services.Backup;
|
||||
|
||||
public sealed class MultilangBackupService
|
||||
{
|
||||
private const string ManifestName = "manifest.json";
|
||||
private const string PluginConfigName = "plugin-config.json";
|
||||
private const string DatabaseName = "translations.sqlite";
|
||||
private const string UserRulesPrefix = "user-rules/";
|
||||
private const string AssetsPrefix = "assets/";
|
||||
|
||||
private readonly TranslationStore _store;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<MultilangBackupService> _logger;
|
||||
|
||||
public MultilangBackupService(
|
||||
TranslationStore store,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
ILogger<MultilangBackupService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public BackupStorageInfo GetStorageInfo()
|
||||
{
|
||||
var (databaseBytes, assetsBytes) = _store.GetStorageUsage();
|
||||
return new BackupStorageInfo(databaseBytes, assetsBytes);
|
||||
}
|
||||
|
||||
public byte[] Export(BackupExportOptions options, PluginConfiguration configuration)
|
||||
=> Export(options, configuration, null);
|
||||
|
||||
public byte[] ExportUserRules(string userId)
|
||||
=> Export(new BackupExportOptions(false, true, false, false), new PluginConfiguration(), NormalizeUserId(userId));
|
||||
|
||||
public byte[] ExportUserRules(string userId, UserRulesDocument rules)
|
||||
{
|
||||
var normalized = NormalizeUserId(userId) ?? userId;
|
||||
var tempDir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var zipPath = Path.Combine(tempDir, "multilang-user-export.zip");
|
||||
using (var file = File.Create(zipPath))
|
||||
using (var zip = new ZipArchive(file, ZipArchiveMode.Create))
|
||||
{
|
||||
AddJson(zip, UserRulesPrefix + normalized + ".json", new BackupUserRules(normalized, rules), CompressionLevel.Optimal);
|
||||
AddJson(zip, ManifestName, new BackupManifest(
|
||||
"Jellyfin.Plugin.Multilang.Export",
|
||||
1,
|
||||
DateTimeOffset.UtcNow,
|
||||
["user-settings"]), CompressionLevel.Optimal);
|
||||
}
|
||||
|
||||
return File.ReadAllBytes(zipPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] Export(BackupExportOptions options, PluginConfiguration configuration, string? onlyUserId)
|
||||
{
|
||||
var tempDir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var sections = new List<string>();
|
||||
var zipPath = Path.Combine(tempDir, "multilang-export.zip");
|
||||
using (var file = File.Create(zipPath))
|
||||
using (var zip = new ZipArchive(file, ZipArchiveMode.Create))
|
||||
{
|
||||
if (options.PluginSettings)
|
||||
{
|
||||
AddJson(zip, PluginConfigName, configuration, CompressionLevel.Optimal);
|
||||
sections.Add("plugin-settings");
|
||||
}
|
||||
|
||||
if (options.UserSettings)
|
||||
{
|
||||
foreach (var user in _store.GetAllUserRules().Where(user => onlyUserId is null || user.Key.Equals(onlyUserId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
AddJson(zip, UserRulesPrefix + user.Key + ".json", new BackupUserRules(user.Key, user.Value), CompressionLevel.Optimal);
|
||||
}
|
||||
|
||||
sections.Add("user-settings");
|
||||
}
|
||||
|
||||
if (options.TranslationsDatabase)
|
||||
{
|
||||
var dbPath = Path.Combine(tempDir, DatabaseName);
|
||||
_store.ExportTranslationDatabase(dbPath);
|
||||
zip.CreateEntryFromFile(dbPath, DatabaseName, CompressionLevel.Optimal);
|
||||
sections.Add("translations-database");
|
||||
}
|
||||
|
||||
if (options.DownloadedAssets)
|
||||
{
|
||||
AddAssets(zip);
|
||||
sections.Add("downloaded-assets");
|
||||
}
|
||||
|
||||
AddJson(zip, ManifestName, new BackupManifest(
|
||||
"Jellyfin.Plugin.Multilang.Export",
|
||||
1,
|
||||
DateTimeOffset.UtcNow,
|
||||
sections.ToArray()), CompressionLevel.Optimal);
|
||||
}
|
||||
|
||||
return File.ReadAllBytes(zipPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
public BackupImportResult ImportAdmin(
|
||||
Stream stream,
|
||||
BackupImportOptions options,
|
||||
PluginConfiguration currentConfiguration,
|
||||
Action<PluginConfiguration> saveConfiguration)
|
||||
{
|
||||
if (options.DownloadedAssets && !options.TranslationsDatabase)
|
||||
throw new InvalidOperationException("Downloaded assets can only be imported together with the translations database.");
|
||||
|
||||
var tempDir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var zipPath = Path.Combine(tempDir, "import.zip");
|
||||
using (var file = File.Create(zipPath))
|
||||
stream.CopyTo(file);
|
||||
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
ValidateManifest(zip);
|
||||
var importedConfig = false;
|
||||
var importedUsers = 0;
|
||||
var ignoredUsers = 0;
|
||||
ImportDatabaseResult database = default;
|
||||
var importedAssetFiles = 0;
|
||||
var deletedUnreferencedAssets = 0;
|
||||
|
||||
if (options.PluginSettings)
|
||||
{
|
||||
var cfg = ReadJsonEntry<PluginConfiguration>(zip, PluginConfigName);
|
||||
if (cfg is not null)
|
||||
{
|
||||
saveConfiguration(cfg);
|
||||
importedConfig = true;
|
||||
currentConfiguration = cfg;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.UserSettings)
|
||||
{
|
||||
var existingUserIds = GetExistingUserIds();
|
||||
foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith(UserRulesPrefix, StringComparison.OrdinalIgnoreCase) && e.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var payload = ReadJsonEntry<BackupUserRules>(entry);
|
||||
var userId = NormalizeUserId(payload?.UserId ?? Path.GetFileNameWithoutExtension(entry.FullName));
|
||||
if (userId is null || !existingUserIds.Contains(userId) || payload?.Rules is null)
|
||||
{
|
||||
ignoredUsers++;
|
||||
continue;
|
||||
}
|
||||
|
||||
_store.SaveUserRules(userId, payload.Rules);
|
||||
importedUsers++;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.TranslationsDatabase)
|
||||
{
|
||||
var dbEntry = zip.GetEntry(DatabaseName);
|
||||
if (dbEntry is not null)
|
||||
{
|
||||
var dbPath = Path.Combine(tempDir, DatabaseName);
|
||||
dbEntry.ExtractToFile(dbPath, overwrite: true);
|
||||
database = _store.ImportTranslationDatabase(dbPath, GetLiveItemIds(), GetAllowedLanguages(currentConfiguration));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.DownloadedAssets)
|
||||
{
|
||||
importedAssetFiles = ImportAssetFiles(zip);
|
||||
deletedUnreferencedAssets = _store.CleanupUnreferencedAssetFiles();
|
||||
}
|
||||
|
||||
return new BackupImportResult(
|
||||
importedConfig,
|
||||
importedUsers,
|
||||
ignoredUsers,
|
||||
database.Facts,
|
||||
database.Translations,
|
||||
database.Assets,
|
||||
database.Genres,
|
||||
importedAssetFiles,
|
||||
deletedUnreferencedAssets,
|
||||
[],
|
||||
false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
public UserBackupInspectResult InspectUserImport(Stream stream, string currentUserId)
|
||||
{
|
||||
var tempDir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var zipPath = Path.Combine(tempDir, "import.zip");
|
||||
using (var file = File.Create(zipPath))
|
||||
stream.CopyTo(file);
|
||||
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
ValidateManifest(zip);
|
||||
var userIds = GetUserRuleIds(zip);
|
||||
var normalizedCurrent = NormalizeUserId(currentUserId) ?? currentUserId;
|
||||
return new UserBackupInspectResult(
|
||||
userIds.Length,
|
||||
userIds.Contains(normalizedCurrent, StringComparer.OrdinalIgnoreCase),
|
||||
userIds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
public BackupImportResult ImportUser(Stream stream, string currentUserId, string? selectedSourceUserId)
|
||||
{
|
||||
var tempDir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var zipPath = Path.Combine(tempDir, "import.zip");
|
||||
using (var file = File.Create(zipPath))
|
||||
stream.CopyTo(file);
|
||||
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
ValidateManifest(zip);
|
||||
var current = NormalizeUserId(currentUserId) ?? currentUserId;
|
||||
var ids = GetUserRuleIds(zip);
|
||||
if (ids.Length == 0)
|
||||
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, [], false);
|
||||
|
||||
var source = ids.FirstOrDefault(id => id.Equals(current, StringComparison.OrdinalIgnoreCase));
|
||||
if (source is null && !string.IsNullOrWhiteSpace(selectedSourceUserId))
|
||||
{
|
||||
var selected = NormalizeUserId(selectedSourceUserId) ?? selectedSourceUserId.Trim();
|
||||
source = ids.FirstOrDefault(id => id.Equals(selected, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (source is null)
|
||||
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, ids, true);
|
||||
|
||||
var payload = ReadJsonEntry<BackupUserRules>(zip, UserRulesPrefix + source + ".json");
|
||||
if (payload?.Rules is null)
|
||||
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, [], false);
|
||||
|
||||
_store.SaveUserRules(current, payload.Rules);
|
||||
return new BackupImportResult(false, 1, 0, 0, 0, 0, 0, 0, 0, [], false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
public int CleanupUnreferencedAssetFiles()
|
||||
=> _store.CleanupUnreferencedAssetFiles();
|
||||
|
||||
private void AddAssets(ZipArchive zip)
|
||||
{
|
||||
if (!Directory.Exists(_store.AssetsDirectory))
|
||||
return;
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(_store.AssetsDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(_store.AssetsDirectory, file).Replace('\\', '/');
|
||||
zip.CreateEntryFromFile(file, AssetsPrefix + relative, CompressionLevel.NoCompression);
|
||||
}
|
||||
}
|
||||
|
||||
private int ImportAssetFiles(ZipArchive zip)
|
||||
{
|
||||
var count = 0;
|
||||
Directory.CreateDirectory(_store.AssetsDirectory);
|
||||
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
|
||||
foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith(AssetsPrefix, StringComparison.OrdinalIgnoreCase) && !e.FullName.EndsWith("/", StringComparison.Ordinal)))
|
||||
{
|
||||
var relative = entry.FullName[AssetsPrefix.Length..].Replace('/', Path.DirectorySeparatorChar);
|
||||
var destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative));
|
||||
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
entry.ExtractToFile(destination, overwrite: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private HashSet<string> GetLiveItemIds()
|
||||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes =
|
||||
[
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Series,
|
||||
BaseItemKind.BoxSet,
|
||||
BaseItemKind.Season,
|
||||
BaseItemKind.Episode
|
||||
],
|
||||
Recursive = true
|
||||
};
|
||||
|
||||
return _libraryManager.GetItemList(query)
|
||||
.Select(item => TranslationStore.ToItemId32(item.Id))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private HashSet<string> GetExistingUserIds()
|
||||
=> _userManager.GetUsersIds().Select(id => id.ToString("N")).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static HashSet<string> GetAllowedLanguages(PluginConfiguration configuration)
|
||||
=> (configuration.Languages ?? []).Where(l => !string.IsNullOrWhiteSpace(l)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static string[] GetUserRuleIds(ZipArchive zip)
|
||||
=> zip.Entries
|
||||
.Where(e => e.FullName.StartsWith(UserRulesPrefix, StringComparison.OrdinalIgnoreCase) && e.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(e => NormalizeUserId(Path.GetFileNameWithoutExtension(e.FullName)))
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static string? NormalizeUserId(string? userId)
|
||||
{
|
||||
userId = (userId ?? string.Empty).Trim();
|
||||
if (Guid.TryParse(userId, out var guid))
|
||||
return guid.ToString("N");
|
||||
|
||||
return userId.Length == 32 && userId.All(Uri.IsHexDigit)
|
||||
? userId.ToLowerInvariant()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void AddJson<T>(ZipArchive zip, string path, T value, CompressionLevel compressionLevel)
|
||||
{
|
||||
var entry = zip.CreateEntry(path, compressionLevel);
|
||||
using var stream = entry.Open();
|
||||
JsonSerializer.Serialize(stream, value, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static T? ReadJsonEntry<T>(ZipArchive zip, string path)
|
||||
{
|
||||
var entry = zip.GetEntry(path);
|
||||
return entry is null ? default : ReadJsonEntry<T>(entry);
|
||||
}
|
||||
|
||||
private static T? ReadJsonEntry<T>(ZipArchiveEntry entry)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
return JsonSerializer.Deserialize<T>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
|
||||
private static void ValidateManifest(ZipArchive zip)
|
||||
{
|
||||
var manifest = ReadJsonEntry<BackupManifest>(zip, ManifestName);
|
||||
if (manifest is null || !manifest.Format.Equals("Jellyfin.Plugin.Multilang.Export", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("This is not a Multilang export file.");
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "multilang-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BackupExportOptions(bool PluginSettings, bool UserSettings, bool TranslationsDatabase, bool DownloadedAssets);
|
||||
|
||||
public sealed record BackupImportOptions(bool PluginSettings, bool UserSettings, bool TranslationsDatabase, bool DownloadedAssets);
|
||||
|
||||
public sealed record BackupStorageInfo(long DatabaseBytes, long AssetsBytes);
|
||||
|
||||
public sealed record BackupManifest(string Format, int Version, DateTimeOffset CreatedUtc, string[] Sections);
|
||||
|
||||
public sealed record BackupUserRules(string UserId, UserRulesDocument Rules);
|
||||
|
||||
public sealed record UserBackupInspectResult(int UserSettingsCount, bool ContainsCurrentUser, string[] UserIds);
|
||||
|
||||
public sealed record BackupImportResult(
|
||||
bool PluginSettingsImported,
|
||||
int UserSettingsImported,
|
||||
int UserSettingsIgnored,
|
||||
int FactsImported,
|
||||
int TranslationsImported,
|
||||
int AssetsImported,
|
||||
int GenresImported,
|
||||
int AssetFilesImported,
|
||||
int AssetFilesDeleted,
|
||||
string[] CandidateUserIds,
|
||||
bool NeedsUserSelection);
|
||||
@@ -38,6 +38,24 @@ internal static class WebScriptInjector
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RemoveInjected(string webRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(webRootPath))
|
||||
return false;
|
||||
|
||||
var indexPath = Path.Combine(webRootPath, "index.html");
|
||||
if (!File.Exists(indexPath))
|
||||
return false;
|
||||
|
||||
var content = File.ReadAllText(indexPath);
|
||||
var cleaned = RemoveOldBlock(content);
|
||||
if (cleaned.Equals(content, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
File.WriteAllText(indexPath, cleaned, Encoding.UTF8);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string RemoveOldBlock(string content)
|
||||
{
|
||||
var start = content.IndexOf(StartComment, StringComparison.Ordinal);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Multilang.Configuration;
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using Jellyfin.Plugin.Multilang.Services.Assets;
|
||||
using Jellyfin.Plugin.Multilang.Services.Providers;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -18,6 +19,7 @@ public sealed class RefreshService
|
||||
private readonly TranslationStore _store;
|
||||
private readonly TmdbClient _tmdbClient;
|
||||
private readonly FanartClient _fanartClient;
|
||||
private readonly AssetStorageService _assetStorage;
|
||||
private readonly RefreshCoordinator _coordinator;
|
||||
private readonly ILogger<RefreshService> _logger;
|
||||
private readonly object _diagnosticsLock = new();
|
||||
@@ -28,6 +30,7 @@ public sealed class RefreshService
|
||||
TranslationStore store,
|
||||
TmdbClient tmdbClient,
|
||||
FanartClient fanartClient,
|
||||
AssetStorageService assetStorage,
|
||||
RefreshCoordinator coordinator,
|
||||
ILogger<RefreshService> logger)
|
||||
{
|
||||
@@ -35,6 +38,7 @@ public sealed class RefreshService
|
||||
_store = store;
|
||||
_tmdbClient = tmdbClient;
|
||||
_fanartClient = fanartClient;
|
||||
_assetStorage = assetStorage;
|
||||
_coordinator = coordinator;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -156,7 +160,19 @@ public sealed class RefreshService
|
||||
var trackedBefore = _store.GetTrackedItemIds();
|
||||
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
|
||||
newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count();
|
||||
_store.CleanupNotInLibrary(liveIds);
|
||||
// Run local housekeeping only from full-library scans. ItemsProxy browsing may enqueue
|
||||
// missing items, but it must not delete local assets just because the admin briefly
|
||||
// changed artwork storage mode.
|
||||
var localCleanup = _store.CleanupForConfiguration(liveIds, NormalizeLanguages(cfg.Languages), IsLocalAssetStorage(cfg));
|
||||
if (localCleanup.TranslationsDeleted > 0 || localCleanup.AssetsDeleted > 0 || localCleanup.GenresDeleted > 0 || localCleanup.AssetFilesDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Multilang local cleanup translations={Translations} assets={Assets} genres={Genres} assetFiles={AssetFiles}",
|
||||
localCleanup.TranslationsDeleted,
|
||||
localCleanup.AssetsDeleted,
|
||||
localCleanup.GenresDeleted,
|
||||
localCleanup.AssetFilesDeleted);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
|
||||
var index = 0;
|
||||
@@ -168,9 +184,10 @@ public sealed class RefreshService
|
||||
SetCurrent(item, index, "checking");
|
||||
var status = _store.GetFactsStatus(item.ItemId);
|
||||
var isNew = !status.Exists;
|
||||
var missingConfiguredData = jobType == RefreshJobType.Missing && status.Exists && ItemNeedsMissingRefresh(item, cfg, langs: null);
|
||||
var due = jobType == RefreshJobType.Full
|
||||
? !status.Exists || status.FullCheckedAt < fullCutoff
|
||||
: !status.Exists || status.MissingCheckedAt < missingCutoff;
|
||||
: !status.Exists || status.MissingCheckedAt < missingCutoff || missingConfiguredData;
|
||||
|
||||
if (!due)
|
||||
{
|
||||
@@ -304,6 +321,8 @@ public sealed class RefreshService
|
||||
|
||||
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
|
||||
{
|
||||
// Browsing can only backfill completely untracked items. It deliberately does not run
|
||||
// local cleanup or convert existing local assets to URL rows.
|
||||
var cfg = Plugin.Instance?.Configuration;
|
||||
if (cfg is null)
|
||||
return false;
|
||||
@@ -530,9 +549,9 @@ public sealed class RefreshService
|
||||
if (tmdbImages is null)
|
||||
continue;
|
||||
|
||||
StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "poster", tmdbImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "logo", tmdbImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
|
||||
continue;
|
||||
}
|
||||
@@ -543,22 +562,24 @@ public sealed class RefreshService
|
||||
if (fanartImages is null)
|
||||
continue;
|
||||
|
||||
StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "poster", fanartImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "logo", fanartImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "banner", fanartImages.Banners, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "thumb", fanartImages.Thumbs, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreImageMap(
|
||||
private async Task StoreImageMapAsync(
|
||||
string itemId,
|
||||
IEnumerable<string> languages,
|
||||
string kind,
|
||||
IReadOnlyDictionary<string, string> images,
|
||||
PluginConfiguration cfg,
|
||||
long updatedAt,
|
||||
HashSet<string> written)
|
||||
HashSet<string> written,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var lang in languages)
|
||||
{
|
||||
@@ -573,11 +594,25 @@ public sealed class RefreshService
|
||||
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
|
||||
continue;
|
||||
|
||||
_store.UpsertAsset(itemId, storeLang, kind, url, updatedAt);
|
||||
var storedPath = await _assetStorage.StoreAsync(itemId, storeLang, kind, url, IsLocalAssetStorage(cfg), cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(storedPath))
|
||||
continue;
|
||||
|
||||
_store.UpsertAsset(itemId, storeLang, kind, storedPath, updatedAt);
|
||||
written.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ItemNeedsMissingRefresh(RefreshItemInfo item, PluginConfiguration cfg, string[]? langs)
|
||||
{
|
||||
langs ??= NormalizeLanguages(cfg.Languages);
|
||||
if (_store.HasMissingConfiguredTranslations(item.ItemId, langs))
|
||||
return true;
|
||||
|
||||
var assetPresence = _store.GetAssetPresence(item.ItemId, langs.Append(OriginalLanguageTag));
|
||||
return IsLocalAssetStorage(cfg) && assetPresence.AnyRemote;
|
||||
}
|
||||
|
||||
private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
|
||||
=> languages
|
||||
.Concat([OriginalLanguageTag + ":" + originalLanguage])
|
||||
@@ -743,6 +778,9 @@ public sealed class RefreshService
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static bool IsLocalAssetStorage(PluginConfiguration cfg)
|
||||
=> string.Equals(cfg.AssetStorageMode, "local", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user