Add backup export and local artwork storage

This commit is contained in:
ajp_anton
2026-06-08 04:19:08 +00:00
parent 63dbc3a6d4
commit a4bb9a98c2
14 changed files with 1745 additions and 46 deletions
@@ -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<MultilangController> _logger;
public MultilangController(
@@ -59,6 +64,8 @@ public sealed class MultilangController : ControllerBase
RefreshService refreshService,
FanartClient fanartClient,
ItemsProxyCache itemsProxyCache,
AssetStorageService assetStorage,
MultilangBackupService backupService,
ILogger<MultilangController> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> 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";
}