From a4bb9a98c25501c8dad2e4f9bd6a6a4239d08902 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Mon, 8 Jun 2026 04:19:08 +0000 Subject: [PATCH] Add backup export and local artwork storage --- .../Api/MultilangController.cs | 191 ++++++- .../Configuration/PluginConfiguration.cs | 4 + .../Configuration/configPage.html | 203 ++++++- .../Configuration/shared.css | 36 ++ .../Configuration/shared.js | 14 + .../Configuration/userRulesPage.html | 133 +++++ .../Data/TranslationStore.cs | 526 +++++++++++++++++- .../Jellyfin.Plugin.Multilang.csproj | 6 +- src/Jellyfin.Plugin.Multilang/Plugin.cs | 18 + .../PluginServiceRegistrator.cs | 4 + .../Services/Assets/AssetStorageService.cs | 138 +++++ .../Services/Backup/MultilangBackupService.cs | 438 +++++++++++++++ .../Services/Injection/WebScriptInjector.cs | 18 + .../Services/Refresh/RefreshService.cs | 62 ++- 14 files changed, 1745 insertions(+), 46 deletions(-) create mode 100644 src/Jellyfin.Plugin.Multilang/Services/Assets/AssetStorageService.cs create mode 100644 src/Jellyfin.Plugin.Multilang/Services/Backup/MultilangBackupService.cs diff --git a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs index 984d989..f571626 100644 --- a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs +++ b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs @@ -7,6 +7,8 @@ using Jellyfin.Plugin.Multilang.Configuration; using Jellyfin.Plugin.Multilang.Data; using Jellyfin.Plugin.Multilang.Rules; using Jellyfin.Plugin.Multilang.Services; +using Jellyfin.Plugin.Multilang.Services.Assets; +using Jellyfin.Plugin.Multilang.Services.Backup; using Jellyfin.Plugin.Multilang.Services.Providers; using Jellyfin.Plugin.Multilang.Services.Refresh; using MediaBrowser.Common.Configuration; @@ -14,6 +16,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -44,6 +47,8 @@ public sealed class MultilangController : ControllerBase private readonly RefreshService _refreshService; private readonly FanartClient _fanartClient; private readonly ItemsProxyCache _itemsProxyCache; + private readonly AssetStorageService _assetStorage; + private readonly MultilangBackupService _backupService; private readonly ILogger _logger; public MultilangController( @@ -59,6 +64,8 @@ public sealed class MultilangController : ControllerBase RefreshService refreshService, FanartClient fanartClient, ItemsProxyCache itemsProxyCache, + AssetStorageService assetStorage, + MultilangBackupService backupService, ILogger logger) { _store = store; @@ -73,6 +80,8 @@ public sealed class MultilangController : ControllerBase _refreshService = refreshService; _fanartClient = fanartClient; _itemsProxyCache = itemsProxyCache; + _assetStorage = assetStorage; + _backupService = backupService; _logger = logger; } @@ -88,6 +97,12 @@ public sealed class MultilangController : ControllerBase public IActionResult SharedCss() => ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css"); + [HttpGet("Assets/{**path}")] + public IActionResult Asset(string path) + => _assetStorage.TryResolveLocalAsset(path, out var fullPath, out var contentType) + ? PhysicalFile(fullPath, contentType) + : NotFound(); + [HttpGet("Providers")] public IActionResult Providers() => Ok(_providerCatalog.Providers); @@ -127,6 +142,7 @@ public sealed class MultilangController : ControllerBase private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next) => !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) || + !string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.OrdinalIgnoreCase) || !ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries); private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer) @@ -963,6 +979,154 @@ public sealed class MultilangController : ControllerBase return Ok(_refreshService.GetDiagnostics()); } + [HttpGet("ExportInfo")] + public async Task ExportInfo() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + var storage = _backupService.GetStorageInfo(); + return Ok(new + { + storage.DatabaseBytes, + storage.AssetsBytes, + CleanupDataOnUninstall = Plugin.Instance?.Configuration?.CleanupDataOnUninstall == true, + AssetStorageMode = Plugin.Instance?.Configuration?.AssetStorageMode ?? "url" + }); + } + + [HttpGet("Export")] + public async Task Export( + [FromQuery] bool pluginSettings = true, + [FromQuery] bool userSettings = true, + [FromQuery] bool translationsDatabase = false, + [FromQuery] bool downloadedAssets = false) + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + if (downloadedAssets && !translationsDatabase) + return BadRequest(new { Error = "AssetsRequireDatabase" }); + + var bytes = _backupService.Export( + new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets), + Plugin.Instance?.Configuration ?? new PluginConfiguration()); + var name = "multilang-export-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".zip"; + return File(bytes, "application/zip", name); + } + + [HttpPost("Import")] + public async Task Import() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false); + var file = form.Files.GetFile("file"); + if (file is null || file.Length == 0) + return BadRequest(new { Error = "MissingFile" }); + + var options = new BackupImportOptions( + FormBool(form, "pluginSettings", true), + FormBool(form, "userSettings", true), + FormBool(form, "translationsDatabase", true), + FormBool(form, "downloadedAssets", true)); + if (options.DownloadedAssets && !options.TranslationsDatabase) + return BadRequest(new { Error = "AssetsRequireDatabase" }); + + try + { + await using var stream = file.OpenReadStream(); + var result = _backupService.ImportAdmin( + stream, + options, + Plugin.Instance?.Configuration ?? new PluginConfiguration(), + cfg => Plugin.Instance?.UpdateConfiguration(cfg)); + _itemsProxyCache.ClearAll(); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpPost("CleanupAll")] + public async Task CleanupAll([FromBody] CleanupAllRequest request) + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + if (request.Confirm != true) + return BadRequest(new { Error = "ConfirmationRequired" }); + + Plugin.Instance?.UpdateConfiguration(new PluginConfiguration()); + _store.ResetAll(); + _itemsProxyCache.ClearAll(); + return Ok(new { Cleared = true }); + } + + [HttpGet("UserRules/self/export")] + public async Task ExportSelfRules() + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + var bytes = _backupService.ExportUserRules(userId, GetRulesOrDefault(userId)); + var name = "multilang-user-rules-" + userId + ".zip"; + return File(bytes, "application/zip", name); + } + + [HttpPost("UserRules/self/import/inspect")] + public async Task InspectSelfRulesImport() + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false); + var file = form.Files.GetFile("file"); + if (file is null || file.Length == 0) + return BadRequest(new { Error = "MissingFile" }); + + try + { + await using var stream = file.OpenReadStream(); + return Ok(_backupService.InspectUserImport(stream, userId)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpPost("UserRules/self/import")] + public async Task ImportSelfRules() + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false); + var file = form.Files.GetFile("file"); + if (file is null || file.Length == 0) + return BadRequest(new { Error = "MissingFile" }); + + try + { + await using var stream = file.OpenReadStream(); + var result = _backupService.ImportUser(stream, userId, form.TryGetValue("sourceUserId", out var sourceUserId) ? sourceUserId.ToString() : null); + if (result.UserSettingsImported > 0) + _itemsProxyCache.ClearAll(); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + [HttpGet("Debug/{itemId}")] public async Task DebugItem(string itemId) { @@ -1989,6 +2153,16 @@ public sealed class MultilangController : ControllerBase return dto.Policy?.IsAdministrator == true; } + private static bool FormBool(IFormCollection form, string key, bool fallback) + { + if (!form.TryGetValue(key, out var values)) + return fallback; + var value = values.ToString(); + return value.Equals("1", StringComparison.OrdinalIgnoreCase) || + value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("on", StringComparison.OrdinalIgnoreCase); + } + private static bool TryGetIsAdmin(object user, out bool isAdmin) { isAdmin = false; @@ -2063,6 +2237,8 @@ public sealed class MultilangController : ControllerBase public sealed record TestProviderRequest(string Provider, string ApiKey); +public sealed record CleanupAllRequest(bool Confirm); + public sealed class AdminConfigDto { public string TmdbApiKey { get; set; } = string.Empty; @@ -2083,12 +2259,16 @@ public sealed class AdminConfigDto public int ItemsProxyCacheMaxMiB { get; set; } + public string AssetStorageMode { get; set; } = "url"; + public SortArticleEntry[] ArticleEntries { get; set; } = []; public bool EnableLogging { get; set; } public bool VerboseLogging { get; set; } + public bool CleanupDataOnUninstall { get; set; } + public static AdminConfigDto FromConfiguration(PluginConfiguration cfg) => new() { @@ -2101,9 +2281,11 @@ public sealed class AdminConfigDto ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs, ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes, ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB, + AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode), ArticleEntries = cfg.ArticleEntries, EnableLogging = cfg.EnableLogging, - VerboseLogging = cfg.VerboseLogging + VerboseLogging = cfg.VerboseLogging, + CleanupDataOnUninstall = cfg.CleanupDataOnUninstall }; public PluginConfiguration ToConfiguration(ProviderCatalog providerCatalog) @@ -2121,12 +2303,14 @@ public sealed class AdminConfigDto ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs), ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes), ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB), + AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode), ArticleEntries = ArticleEntries .Where(e => !string.IsNullOrWhiteSpace(e.Language)) .Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply }) .ToArray(), EnableLogging = EnableLogging || VerboseLogging, - VerboseLogging = VerboseLogging + VerboseLogging = VerboseLogging, + CleanupDataOnUninstall = CleanupDataOnUninstall }; private static string[] NormalizeLanguages(IEnumerable languages) @@ -2134,4 +2318,7 @@ public sealed class AdminConfigDto .Where(l => l.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + + private static string NormalizeAssetStorageMode(string? value) + => string.Equals(value?.Trim(), "local", StringComparison.OrdinalIgnoreCase) ? "local" : "url"; } diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs b/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs index f319d3e..3ad5c7d 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs +++ b/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs @@ -27,6 +27,8 @@ public sealed class PluginConfiguration : BasePluginConfiguration public int ItemsProxyCacheMaxMiB { get; set; } = 50; + public string AssetStorageMode { get; set; } = "url"; + [XmlArray("SortArticles")] [XmlArrayItem("SortArticleEntry")] public SortArticleEntry[] ArticleEntries { get; set; } = []; @@ -34,6 +36,8 @@ public sealed class PluginConfiguration : BasePluginConfiguration public bool EnableLogging { get; set; } public bool VerboseLogging { get; set; } + + public bool CleanupDataOnUninstall { get; set; } } public sealed class ProviderState diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html index e230121..4ab242b 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html +++ b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html @@ -49,6 +49,14 @@
Scanning task +
+ + +
+
@@ -105,12 +113,68 @@
+
+ Backup and cleanup +
+ +
+
+ + + +
+
+
+
+ + + + + +