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.Data;
using Jellyfin.Plugin.Multilang.Rules; using Jellyfin.Plugin.Multilang.Rules;
using Jellyfin.Plugin.Multilang.Services; 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.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh; using Jellyfin.Plugin.Multilang.Services.Refresh;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
@@ -14,6 +16,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -44,6 +47,8 @@ public sealed class MultilangController : ControllerBase
private readonly RefreshService _refreshService; private readonly RefreshService _refreshService;
private readonly FanartClient _fanartClient; private readonly FanartClient _fanartClient;
private readonly ItemsProxyCache _itemsProxyCache; private readonly ItemsProxyCache _itemsProxyCache;
private readonly AssetStorageService _assetStorage;
private readonly MultilangBackupService _backupService;
private readonly ILogger<MultilangController> _logger; private readonly ILogger<MultilangController> _logger;
public MultilangController( public MultilangController(
@@ -59,6 +64,8 @@ public sealed class MultilangController : ControllerBase
RefreshService refreshService, RefreshService refreshService,
FanartClient fanartClient, FanartClient fanartClient,
ItemsProxyCache itemsProxyCache, ItemsProxyCache itemsProxyCache,
AssetStorageService assetStorage,
MultilangBackupService backupService,
ILogger<MultilangController> logger) ILogger<MultilangController> logger)
{ {
_store = store; _store = store;
@@ -73,6 +80,8 @@ public sealed class MultilangController : ControllerBase
_refreshService = refreshService; _refreshService = refreshService;
_fanartClient = fanartClient; _fanartClient = fanartClient;
_itemsProxyCache = itemsProxyCache; _itemsProxyCache = itemsProxyCache;
_assetStorage = assetStorage;
_backupService = backupService;
_logger = logger; _logger = logger;
} }
@@ -88,6 +97,12 @@ public sealed class MultilangController : ControllerBase
public IActionResult SharedCss() public IActionResult SharedCss()
=> ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css"); => 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")] [HttpGet("Providers")]
public IActionResult Providers() public IActionResult Providers()
=> Ok(_providerCatalog.Providers); => Ok(_providerCatalog.Providers);
@@ -127,6 +142,7 @@ public sealed class MultilangController : ControllerBase
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next) private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) || => !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
!string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.OrdinalIgnoreCase) ||
!ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries); !ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries);
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer) private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
@@ -963,6 +979,154 @@ public sealed class MultilangController : ControllerBase
return Ok(_refreshService.GetDiagnostics()); 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}")] [HttpGet("Debug/{itemId}")]
public async Task<IActionResult> DebugItem(string itemId) public async Task<IActionResult> DebugItem(string itemId)
{ {
@@ -1989,6 +2153,16 @@ public sealed class MultilangController : ControllerBase
return dto.Policy?.IsAdministrator == true; 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) private static bool TryGetIsAdmin(object user, out bool isAdmin)
{ {
isAdmin = false; isAdmin = false;
@@ -2063,6 +2237,8 @@ public sealed class MultilangController : ControllerBase
public sealed record TestProviderRequest(string Provider, string ApiKey); public sealed record TestProviderRequest(string Provider, string ApiKey);
public sealed record CleanupAllRequest(bool Confirm);
public sealed class AdminConfigDto public sealed class AdminConfigDto
{ {
public string TmdbApiKey { get; set; } = string.Empty; public string TmdbApiKey { get; set; } = string.Empty;
@@ -2083,12 +2259,16 @@ public sealed class AdminConfigDto
public int ItemsProxyCacheMaxMiB { get; set; } public int ItemsProxyCacheMaxMiB { get; set; }
public string AssetStorageMode { get; set; } = "url";
public SortArticleEntry[] ArticleEntries { get; set; } = []; public SortArticleEntry[] ArticleEntries { get; set; } = [];
public bool EnableLogging { get; set; } public bool EnableLogging { get; set; }
public bool VerboseLogging { get; set; } public bool VerboseLogging { get; set; }
public bool CleanupDataOnUninstall { get; set; }
public static AdminConfigDto FromConfiguration(PluginConfiguration cfg) public static AdminConfigDto FromConfiguration(PluginConfiguration cfg)
=> new() => new()
{ {
@@ -2101,9 +2281,11 @@ public sealed class AdminConfigDto
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs, ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes, ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB, ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
ArticleEntries = cfg.ArticleEntries, ArticleEntries = cfg.ArticleEntries,
EnableLogging = cfg.EnableLogging, EnableLogging = cfg.EnableLogging,
VerboseLogging = cfg.VerboseLogging VerboseLogging = cfg.VerboseLogging,
CleanupDataOnUninstall = cfg.CleanupDataOnUninstall
}; };
public PluginConfiguration ToConfiguration(ProviderCatalog providerCatalog) public PluginConfiguration ToConfiguration(ProviderCatalog providerCatalog)
@@ -2121,12 +2303,14 @@ public sealed class AdminConfigDto
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs), ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes), ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB), ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
ArticleEntries = ArticleEntries ArticleEntries = ArticleEntries
.Where(e => !string.IsNullOrWhiteSpace(e.Language)) .Where(e => !string.IsNullOrWhiteSpace(e.Language))
.Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply }) .Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply })
.ToArray(), .ToArray(),
EnableLogging = EnableLogging || VerboseLogging, EnableLogging = EnableLogging || VerboseLogging,
VerboseLogging = VerboseLogging VerboseLogging = VerboseLogging,
CleanupDataOnUninstall = CleanupDataOnUninstall
}; };
private static string[] NormalizeLanguages(IEnumerable<string> languages) private static string[] NormalizeLanguages(IEnumerable<string> languages)
@@ -2134,4 +2318,7 @@ public sealed class AdminConfigDto
.Where(l => l.Length > 0) .Where(l => l.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
private static string NormalizeAssetStorageMode(string? value)
=> string.Equals(value?.Trim(), "local", StringComparison.OrdinalIgnoreCase) ? "local" : "url";
} }
@@ -27,6 +27,8 @@ public sealed class PluginConfiguration : BasePluginConfiguration
public int ItemsProxyCacheMaxMiB { get; set; } = 50; public int ItemsProxyCacheMaxMiB { get; set; } = 50;
public string AssetStorageMode { get; set; } = "url";
[XmlArray("SortArticles")] [XmlArray("SortArticles")]
[XmlArrayItem("SortArticleEntry")] [XmlArrayItem("SortArticleEntry")]
public SortArticleEntry[] ArticleEntries { get; set; } = []; public SortArticleEntry[] ArticleEntries { get; set; } = [];
@@ -34,6 +36,8 @@ public sealed class PluginConfiguration : BasePluginConfiguration
public bool EnableLogging { get; set; } public bool EnableLogging { get; set; }
public bool VerboseLogging { get; set; } public bool VerboseLogging { get; set; }
public bool CleanupDataOnUninstall { get; set; }
} }
public sealed class ProviderState public sealed class ProviderState
@@ -49,6 +49,14 @@
<fieldset class="ml-section"> <fieldset class="ml-section">
<legend>Scanning task</legend> <legend>Scanning task</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-asset-storage">Artwork storage</label>
<select id="ml-asset-storage" is="emby-select" class="emby-select-withcolor emby-select ml-input">
<option value="url">Use provider URLs</option>
<option value="local">Download locally</option>
</select>
<div id="ml-asset-storage-info" class="fieldDescription"></div>
</div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-missing-days">Wait days before trying to find missing data</label> <label class="inputLabel inputLabelUnfocused" for="ml-missing-days">Wait days before trying to find missing data</label>
<input id="ml-missing-days" type="number" class="emby-input ml-input" min="0" step="1"> <input id="ml-missing-days" type="number" class="emby-input ml-input" min="0" step="1">
@@ -105,12 +113,68 @@
</div> </div>
</fieldset> </fieldset>
<fieldset class="ml-section">
<legend>Backup and cleanup</legend>
<div class="inputContainer">
<label><input id="ml-cleanup-uninstall" type="checkbox"> Clean up Multilang settings, database, and assets on uninstall</label>
</div>
<div class="ml-actions">
<button id="ml-export-open" is="emby-button" type="button" class="raised"><span>Export...</span></button>
<button id="ml-import-open" is="emby-button" type="button" class="raised"><span>Import...</span></button>
<button id="ml-cleanup-open" is="emby-button" type="button" class="raised ml-danger"><span>Clean up everything...</span></button>
</div>
<div id="ml-storage-info" class="fieldDescription"></div>
</fieldset>
<button id="ml-save" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button> <button id="ml-save" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button> <button id="ml-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form> </form>
<div id="ml-status" class="ml-status"></div> <div id="ml-status" class="ml-status"></div>
<div id="ml-export-modal" class="ml-modal" hidden>
<div class="ml-modal-panel">
<h2>Export Multilang data</h2>
<label><input id="ml-export-plugin" type="checkbox" checked> Admin configuration</label>
<label><input id="ml-export-users" type="checkbox" checked> User settings</label>
<label><input id="ml-export-db" type="checkbox"> Translations database <span id="ml-export-db-size" class="ml-muted"></span></label>
<label><input id="ml-export-assets" type="checkbox"> Downloaded assets <span id="ml-export-assets-size" class="ml-muted"></span></label>
<div class="ml-actions">
<button id="ml-export-run" is="emby-button" type="button" class="raised button-submit"><span>Export</span></button>
<button data-modal-close is="emby-button" type="button" class="raised"><span>Cancel</span></button>
</div>
</div>
</div>
<div id="ml-import-modal" class="ml-modal" hidden>
<div class="ml-modal-panel">
<h2>Import Multilang data</h2>
<label><input id="ml-import-plugin" type="checkbox" checked> Admin configuration</label>
<label><input id="ml-import-users" type="checkbox" checked> User settings</label>
<label><input id="ml-import-db" type="checkbox" checked> Translations database <span id="ml-import-db-size" class="ml-muted"></span></label>
<label><input id="ml-import-assets" type="checkbox" checked> Downloaded assets <span id="ml-import-assets-size" class="ml-muted"></span></label>
<div class="inputContainer">
<input id="ml-import-file" type="file" accept=".zip,application/zip">
</div>
<div class="ml-actions">
<button id="ml-import-run" is="emby-button" type="button" class="raised button-submit"><span>Import</span></button>
<button data-modal-close is="emby-button" type="button" class="raised"><span>Cancel</span></button>
</div>
</div>
</div>
<div id="ml-cleanup-modal" class="ml-modal" hidden>
<div class="ml-modal-panel">
<h2>Clean up everything</h2>
<p>This resets the admin configuration and deletes all Multilang user settings, translation data, and downloaded assets.</p>
<label><input id="ml-cleanup-confirm" type="checkbox"> I understand this cannot be undone unless I have an export.</label>
<div class="ml-actions">
<button id="ml-cleanup-run" is="emby-button" type="button" class="raised ml-danger"><span>Clean up everything</span></button>
<button data-modal-close is="emby-button" type="button" class="raised"><span>Cancel</span></button>
</div>
</div>
</div>
<script src="/Multilang/shared.js"></script> <script src="/Multilang/shared.js"></script>
<script> <script>
(function () { (function () {
@@ -297,6 +361,8 @@
} }
function collect() { function collect() {
var previousStorageMode = (state.config.AssetStorageMode || state.config.assetStorageMode || "url").toLowerCase() === "local" ? "local" : "url";
var nextStorageMode = document.getElementById("ml-asset-storage").value === "local" ? "local" : "url";
document.querySelectorAll("[data-key]").forEach(function (input) { document.querySelectorAll("[data-key]").forEach(function (input) {
state.config[input.dataset.key] = input.value; state.config[input.dataset.key] = input.value;
}); });
@@ -316,8 +382,10 @@
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0); state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0);
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1); state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1); state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1);
state.config.AssetStorageMode = nextStorageMode;
state.config.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked; state.config.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked;
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked; state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
state.config.CleanupDataOnUninstall = document.getElementById("ml-cleanup-uninstall").checked;
state.config.ArticleEntries = Array.prototype.slice.call(articleList.children).map(function (row) { state.config.ArticleEntries = Array.prototype.slice.call(articleList.children).map(function (row) {
return { return {
Language: normalizeLang(row.querySelector(".ml-articles-lang").value), Language: normalizeLang(row.querySelector(".ml-articles-lang").value),
@@ -325,6 +393,7 @@
AlwaysApply: row.querySelector(".ml-articles-apply-toggle").checked AlwaysApply: row.querySelector(".ml-articles-apply-toggle").checked
}; };
}).filter(function (entry) { return entry.Language; }); }).filter(function (entry) { return entry.Language; });
state.config._previousAssetStorageMode = previousStorageMode;
return state.config; return state.config;
} }
@@ -335,13 +404,16 @@
document.getElementById("ml-cache-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000; document.getElementById("ml-cache-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000;
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120; document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50; document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50;
document.getElementById("ml-asset-storage").value = (state.config.AssetStorageMode || state.config.assetStorageMode || "url").toLowerCase() === "local" ? "local" : "url";
document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging; document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging;
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging; document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
renderProviderInputs(); renderProviderInputs();
renderProviderBuckets(); renderProviderBuckets();
renderArticles(); renderArticles();
loadCacheDiagnostics(); loadCacheDiagnostics();
loadRefreshDiagnostics(); loadRefreshDiagnostics();
loadExportInfo();
} }
function renderCacheEntries(entries, error) { function renderCacheEntries(entries, error) {
@@ -557,6 +629,113 @@
} }
} }
function formatBytes(bytes) {
bytes = Number(bytes || 0);
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB";
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + " MiB";
return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GiB";
}
async function loadExportInfo() {
try {
var info = await M.get("/Multilang/ExportInfo");
var db = info.DatabaseBytes ?? info.databaseBytes ?? 0;
var assets = info.AssetsBytes ?? info.assetsBytes ?? 0;
document.getElementById("ml-storage-info").textContent = "Translations database: " + formatBytes(db) + ". Downloaded assets: " + formatBytes(assets) + ".";
updateAssetStorageInfo(assets);
document.getElementById("ml-export-db-size").textContent = "(" + formatBytes(db) + ")";
document.getElementById("ml-export-assets-size").textContent = "(" + formatBytes(assets) + ")";
document.getElementById("ml-import-db-size").textContent = "(" + formatBytes(db) + " currently)";
document.getElementById("ml-import-assets-size").textContent = "(" + formatBytes(assets) + " currently)";
} catch (_) {
}
}
function showModal(id) {
document.getElementById(id).hidden = false;
}
function hideModals() {
Array.prototype.slice.call(document.querySelectorAll(".ml-modal")).forEach(function (modal) { modal.hidden = true; });
}
function syncAssetCheckboxes() {
document.getElementById("ml-export-assets").disabled = !document.getElementById("ml-export-db").checked;
if (document.getElementById("ml-export-assets").disabled) document.getElementById("ml-export-assets").checked = false;
document.getElementById("ml-import-assets").disabled = !document.getElementById("ml-import-db").checked;
if (document.getElementById("ml-import-assets").disabled) document.getElementById("ml-import-assets").checked = false;
}
function updateAssetStorageInfo(assetBytes) {
var mode = document.getElementById("ml-asset-storage").value;
var size = formatBytes(assetBytes || 0);
document.getElementById("ml-asset-storage-info").textContent = mode === "local"
? "Downloaded artwork currently uses " + size + ". Missing local assets will be downloaded during the next scheduled refresh."
: "Artwork is stored as provider URLs. Downloaded local artwork currently uses " + size + " and will be removed by the next scheduled refresh.";
}
async function downloadExport() {
var params = new URLSearchParams({
pluginSettings: document.getElementById("ml-export-plugin").checked ? "true" : "false",
userSettings: document.getElementById("ml-export-users").checked ? "true" : "false",
translationsDatabase: document.getElementById("ml-export-db").checked ? "true" : "false",
downloadedAssets: document.getElementById("ml-export-assets").checked ? "true" : "false"
});
M.setStatus(status, "Creating export...");
var response = await fetch(M.basePath() + "/Multilang/Export?" + params.toString(), {
cache: "no-store",
headers: M.authHeaders()
});
if (!response.ok) throw new Error("HTTP " + response.status);
var blob = await response.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "multilang-export.zip";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
hideModals();
M.setStatus(status, "Export created.");
}
async function runImport() {
var file = document.getElementById("ml-import-file").files[0];
if (!file) throw new Error("Choose an export zip first.");
var formData = new FormData();
formData.append("file", file);
formData.append("pluginSettings", document.getElementById("ml-import-plugin").checked ? "true" : "false");
formData.append("userSettings", document.getElementById("ml-import-users").checked ? "true" : "false");
formData.append("translationsDatabase", document.getElementById("ml-import-db").checked ? "true" : "false");
formData.append("downloadedAssets", document.getElementById("ml-import-assets").checked ? "true" : "false");
M.setStatus(status, "Importing...");
var response = await fetch(M.basePath() + "/Multilang/Import", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: formData
});
if (!response.ok) throw new Error("HTTP " + response.status);
var result = await response.json();
hideModals();
await load();
M.setStatus(status, "Import complete. Users: " + (result.UserSettingsImported ?? result.userSettingsImported ?? 0) + ", translations: " + (result.TranslationsImported ?? result.translationsImported ?? 0) + ".");
}
async function runCleanupAll() {
if (!document.getElementById("ml-cleanup-confirm").checked) {
M.setStatus(status, "Confirm cleanup first.", true);
return;
}
M.setStatus(status, "Cleaning up...");
await M.post("/Multilang/CleanupAll", { Confirm: true });
hideModals();
await load();
M.setStatus(status, "Multilang data cleaned up.");
}
async function load() { async function load() {
M.setStatus(status, "Loading..."); M.setStatus(status, "Loading...");
try { try {
@@ -692,7 +871,17 @@
event.preventDefault(); event.preventDefault();
M.setStatus(status, "Saving..."); M.setStatus(status, "Saving...");
try { try {
state.config = await M.post("/Multilang/AdminConfig", collect()); var next = collect();
if (next._previousAssetStorageMode === "local" && next.AssetStorageMode === "url" &&
!window.confirm("Switching to provider URLs will remove downloaded local artwork during the next scheduled refresh. Continue?")) {
M.setStatus(status, "Not saved.");
return;
}
if (next._previousAssetStorageMode === "url" && next.AssetStorageMode === "local") {
window.alert("Local artwork downloads will start during the next scheduled refresh.");
}
delete next._previousAssetStorageMode;
state.config = await M.post("/Multilang/AdminConfig", next);
loadIntoForm(); loadIntoForm();
M.setStatus(status, "Saved."); M.setStatus(status, "Saved.");
} catch (err) { } catch (err) {
@@ -700,9 +889,21 @@
} }
}); });
document.getElementById("ml-reload").addEventListener("click", load); document.getElementById("ml-reload").addEventListener("click", load);
document.getElementById("ml-asset-storage").addEventListener("change", function () { loadExportInfo(); });
document.getElementById("ml-cache-refresh").addEventListener("click", loadCacheDiagnostics); document.getElementById("ml-cache-refresh").addEventListener("click", loadCacheDiagnostics);
document.getElementById("ml-cache-clear").addEventListener("click", clearCacheEntries); document.getElementById("ml-cache-clear").addEventListener("click", clearCacheEntries);
document.getElementById("ml-refresh-activity-refresh").addEventListener("click", loadRefreshDiagnostics); document.getElementById("ml-refresh-activity-refresh").addEventListener("click", loadRefreshDiagnostics);
document.getElementById("ml-export-open").addEventListener("click", function () { loadExportInfo(); syncAssetCheckboxes(); showModal("ml-export-modal"); });
document.getElementById("ml-import-open").addEventListener("click", function () { loadExportInfo(); syncAssetCheckboxes(); showModal("ml-import-modal"); });
document.getElementById("ml-cleanup-open").addEventListener("click", function () { document.getElementById("ml-cleanup-confirm").checked = false; showModal("ml-cleanup-modal"); });
document.getElementById("ml-export-db").addEventListener("change", syncAssetCheckboxes);
document.getElementById("ml-import-db").addEventListener("change", syncAssetCheckboxes);
document.getElementById("ml-export-run").addEventListener("click", function () { downloadExport().catch(function (err) { M.setStatus(status, String(err), true); }); });
document.getElementById("ml-import-run").addEventListener("click", function () { runImport().catch(function (err) { M.setStatus(status, String(err), true); }); });
document.getElementById("ml-cleanup-run").addEventListener("click", function () { runCleanupAll().catch(function (err) { M.setStatus(status, String(err), true); }); });
Array.prototype.slice.call(document.querySelectorAll("[data-modal-close]")).forEach(function (button) {
button.addEventListener("click", hideModals);
});
document.getElementById("ml-log-enabled").addEventListener("change", function () { document.getElementById("ml-log-enabled").addEventListener("change", function () {
if (!this.checked) document.getElementById("ml-log-verbose").checked = false; if (!this.checked) document.getElementById("ml-log-verbose").checked = false;
}); });
@@ -36,6 +36,42 @@ body {
font-weight: 600; font-weight: 600;
} }
.ml-modal[hidden] {
display: none;
}
.ml-modal {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, .68);
}
.ml-modal-panel {
width: min(34rem, 100%);
max-height: calc(100vh - 2rem);
overflow: auto;
box-sizing: border-box;
padding: 1.25rem;
border: 1px solid rgba(255, 255, 255, .22);
border-radius: 8px;
background: #181818;
box-shadow: 0 18px 60px rgba(0, 0, 0, .45);
}
.ml-modal-panel h2 {
margin-top: 0;
}
.ml-modal-panel label {
display: block;
margin: .65rem 0;
}
.ml-grid { .ml-grid {
display: grid; display: grid;
gap: .8rem; gap: .8rem;
@@ -25,6 +25,18 @@
return h; return h;
} }
function authHeaders(contentType) {
const qs = new URLSearchParams(location.search);
const t = token() || qs.get("token") || qs.get("api_key") || "";
const h = {};
if (contentType) h["Content-Type"] = contentType;
if (t) {
h["X-Emby-Token"] = t;
h["X-MediaBrowser-Token"] = t;
}
return h;
}
async function request(path, options) { async function request(path, options) {
const response = await fetch(`${basePath()}${path}`, { const response = await fetch(`${basePath()}${path}`, {
cache: "no-store", cache: "no-store",
@@ -89,6 +101,8 @@
window.Multilang = { window.Multilang = {
get: (path) => request(path), get: (path) => request(path),
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }), post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
basePath,
authHeaders,
splitList, splitList,
setStatus, setStatus,
chip, chip,
@@ -31,12 +31,37 @@
<div id="ml-action-matrix" class="ml-action-matrix"></div> <div id="ml-action-matrix" class="ml-action-matrix"></div>
</fieldset> </fieldset>
<fieldset class="ml-section">
<legend>Import and export</legend>
<div class="ml-actions">
<button id="ml-user-export" is="emby-button" type="button" class="raised"><span>Export my settings</span></button>
<button id="ml-user-import-open" is="emby-button" type="button" class="raised"><span>Import settings...</span></button>
</div>
</fieldset>
<button id="ml-save-user" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button> <button id="ml-save-user" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button> <button id="ml-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form> </form>
<div id="ml-user-status" class="ml-status"></div> <div id="ml-user-status" class="ml-status"></div>
<div id="ml-user-import-modal" class="ml-modal" hidden>
<div class="ml-modal-panel">
<h2>Import user settings</h2>
<div class="inputContainer">
<input id="ml-user-import-file" type="file" accept=".zip,application/zip">
</div>
<div id="ml-user-import-choice" class="inputContainer" hidden>
<label class="inputLabel inputLabelUnfocused" for="ml-user-import-source">Import settings from user ID</label>
<select id="ml-user-import-source" class="emby-select ml-select"></select>
</div>
<div class="ml-actions">
<button id="ml-user-import-run" is="emby-button" type="button" class="raised button-submit"><span>Import</span></button>
<button id="ml-user-import-cancel" is="emby-button" type="button" class="raised"><span>Cancel</span></button>
</div>
</div>
</div>
<script src="/Multilang/shared.js"></script> <script src="/Multilang/shared.js"></script>
<script> <script>
(function () { (function () {
@@ -1131,6 +1156,103 @@
} }
} }
async function downloadUserExport() {
M.setStatus(status, "Creating export...");
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/export", {
cache: "no-store",
headers: M.authHeaders()
});
if (!response.ok) throw new Error("HTTP " + response.status);
var blob = await response.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "multilang-user-settings.zip";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
M.setStatus(status, "Export created.");
}
function userImportModal(show) {
$("ml-user-import-modal").hidden = !show;
if (!show) {
$("ml-user-import-choice").hidden = true;
$("ml-user-import-source").replaceChildren();
}
}
function userImportFormData(sourceUserId) {
var file = $("ml-user-import-file").files[0];
if (!file) throw new Error("Choose an export zip first.");
var formData = new FormData();
formData.append("file", file);
if (sourceUserId) formData.append("sourceUserId", sourceUserId);
return formData;
}
async function inspectUserImport() {
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/import/inspect", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: userImportFormData("")
});
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
}
async function postUserImport(sourceUserId) {
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/import", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: userImportFormData(sourceUserId)
});
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
}
async function runUserImport() {
M.setStatus(status, "Inspecting import...");
var choice = $("ml-user-import-choice");
if (!choice.hidden) {
var selected = $("ml-user-import-source").value;
if (!selected) throw new Error("Choose a source user ID.");
var chosen = await postUserImport(selected);
if ((chosen.UserSettingsImported ?? chosen.userSettingsImported ?? 0) <= 0) throw new Error("No user settings were imported.");
userImportModal(false);
await load();
M.setStatus(status, "Imported user settings.");
return;
}
var inspect = await inspectUserImport();
var count = inspect.UserSettingsCount ?? inspect.userSettingsCount ?? 0;
var containsCurrent = inspect.ContainsCurrentUser ?? inspect.containsCurrentUser;
var ids = inspect.UserIds || inspect.userIds || [];
if (count <= 0) {
M.setStatus(status, "No user settings were found in this export.", true);
return;
}
if (containsCurrent) {
var result = await postUserImport("");
if ((result.UserSettingsImported ?? result.userSettingsImported ?? 0) <= 0) throw new Error("No user settings were imported.");
userImportModal(false);
await load();
M.setStatus(status, "Imported user settings.");
return;
}
var select = $("ml-user-import-source");
select.replaceChildren();
ids.forEach(function (id) { select.appendChild(option(id, id, false)); });
choice.hidden = false;
M.setStatus(status, "No matching user ID was found. Choose which exported user settings to import.", true);
}
function bindEvents() { function bindEvents() {
if (page.dataset.bound === "1") return; if (page.dataset.bound === "1") return;
page.dataset.bound = "1"; page.dataset.bound = "1";
@@ -1147,6 +1269,17 @@
}); });
$("ml-reset-user").addEventListener("click", load); $("ml-reset-user").addEventListener("click", load);
$("ml-sort-locale").addEventListener("change", renderSortLocaleAutoText); $("ml-sort-locale").addEventListener("change", renderSortLocaleAutoText);
$("ml-user-export").addEventListener("click", function () {
downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); });
});
$("ml-user-import-open").addEventListener("click", function () {
$("ml-user-import-file").value = "";
userImportModal(true);
});
$("ml-user-import-cancel").addEventListener("click", function () { userImportModal(false); });
$("ml-user-import-run").addEventListener("click", function () {
runUserImport().catch(function (err) { M.setStatus(status, String(err), true); });
});
document.addEventListener("click", function () { closeValuePanels(null); }); document.addEventListener("click", function () { closeValuePanels(null); });
$("ml-add-category").addEventListener("click", function () { $("ml-add-category").addEventListener("click", function () {
syncCategoriesFromDom(); syncCategoriesFromDom();
@@ -9,35 +9,9 @@ namespace Jellyfin.Plugin.Multilang.Data;
public sealed class TranslationStore public sealed class TranslationStore
{ {
private readonly string _dbPath; public const string LocalAssetUrlPrefix = "/Multilang/Assets/";
public TranslationStore(IApplicationPaths appPaths) private const string SchemaSql = @"
{
var dir = Path.Combine(appPaths.DataPath, "multilang");
Directory.CreateDirectory(dir);
Directory.CreateDirectory(Path.Combine(dir, "assets"));
_dbPath = Path.Combine(dir, "translations.sqlite");
Initialize();
}
public static string ToItemId32(Guid id)
=> id.ToString("N", CultureInfo.InvariantCulture).ToLowerInvariant();
public static long NowUnixUtc()
=> DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public static string JsonArray(IEnumerable<string> values)
=> JsonSerializer.Serialize(values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
public SqliteConnection Open()
=> new($"Data Source={_dbPath};Cache=Shared");
private void Initialize()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS facts ( CREATE TABLE IF NOT EXISTS facts (
item_id TEXT PRIMARY KEY, item_id TEXT PRIMARY KEY,
tmdb_id TEXT NOT NULL, tmdb_id TEXT NOT NULL,
@@ -107,10 +81,70 @@ CREATE TABLE IF NOT EXISTS scan_state (
); );
INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0); INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0);
"; ";
private readonly string _dataDir;
private readonly string _assetsDir;
private readonly string _dbPath;
public TranslationStore(IApplicationPaths appPaths)
{
_dataDir = Path.Combine(appPaths.DataPath, "multilang");
_assetsDir = Path.Combine(_dataDir, "assets");
Directory.CreateDirectory(_dataDir);
Directory.CreateDirectory(_assetsDir);
_dbPath = Path.Combine(_dataDir, "translations.sqlite");
Initialize();
}
public string DataDirectory => _dataDir;
public string AssetsDirectory => _assetsDir;
public string DatabasePath => _dbPath;
public static string ToItemId32(Guid id)
=> id.ToString("N", CultureInfo.InvariantCulture).ToLowerInvariant();
public static long NowUnixUtc()
=> DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public static string JsonArray(IEnumerable<string> values)
=> JsonSerializer.Serialize(values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
public SqliteConnection Open()
=> new($"Data Source={_dbPath};Cache=Shared;Pooling=False");
private void Initialize()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = SchemaSql;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
EnsureColumn(con, "assets", "path_low", "TEXT NOT NULL DEFAULT ''"); EnsureColumn(con, "assets", "path_low", "TEXT NOT NULL DEFAULT ''");
} }
public void ResetAll()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(_dataDir))
Directory.Delete(_dataDir, recursive: true);
Directory.CreateDirectory(_dataDir);
Directory.CreateDirectory(_assetsDir);
Initialize();
SqliteConnection.ClearAllPools();
}
public (long DatabaseBytes, long AssetsBytes) GetStorageUsage()
=> (File.Exists(_dbPath) ? new FileInfo(_dbPath).Length : 0, DirectorySize(_assetsDir));
private static long DirectorySize(string path)
{
if (!Directory.Exists(path))
return 0;
return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).Sum(file => new FileInfo(file).Length);
}
private static void EnsureColumn(SqliteConnection con, string table, string column, string definition) private static void EnsureColumn(SqliteConnection con, string table, string column, string definition)
{ {
using (var info = con.CreateCommand()) using (var info = con.CreateCommand())
@@ -173,6 +207,137 @@ DELETE FROM assets WHERE item_id = $item_id;";
} }
tx.Commit(); tx.Commit();
CleanupUnreferencedAssetFiles();
}
public LocalCleanupResult CleanupForConfiguration(IReadOnlySet<string> liveItemIds, IEnumerable<string> allowedLanguages, bool localAssetStorage)
{
CleanupNotInLibrary(liveItemIds);
var allowed = allowedLanguages
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => l.Trim())
.Append("Original")
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var translationsDeleted = DeleteRowsOutsideLanguages("translations", allowed);
var genresDeleted = DeleteRowsOutsideLanguages("genres", allowed);
var assetsDeleted = DeleteRowsOutsideLanguages("assets", allowed);
var modeRowsDeleted = localAssetStorage ? 0 : DeleteLocalAssetRows(resetMissingCheckedAt: true);
var assetFilesDeleted = CleanupUnreferencedAssetFiles();
return new LocalCleanupResult(translationsDeleted, assetsDeleted + modeRowsDeleted, genresDeleted, assetFilesDeleted);
}
private int DeleteRowsOutsideLanguages(string table, IReadOnlyList<string> allowedLanguages)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var langParams = AddParams(cmd, allowedLanguages, "$lang");
cmd.CommandText = $"DELETE FROM {table} WHERE lang NOT IN ({langParams});";
return cmd.ExecuteNonQuery();
}
private int DeleteLocalAssetRows(bool resetMissingCheckedAt)
{
using var con = Open();
con.Open();
using var tx = con.BeginTransaction();
var affected = new List<string>();
using (var select = con.CreateCommand())
{
select.Transaction = tx;
select.CommandText = "SELECT DISTINCT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%';";
using var reader = select.ExecuteReader();
while (reader.Read())
affected.Add(reader.GetString(0));
}
int deleted;
using (var delete = con.CreateCommand())
{
delete.Transaction = tx;
delete.CommandText = "DELETE FROM assets WHERE path_low LIKE '/multilang/assets/%';";
deleted = delete.ExecuteNonQuery();
}
if (resetMissingCheckedAt && affected.Count > 0)
{
using var update = con.CreateCommand();
update.Transaction = tx;
var idParams = AddParams(update, affected, "$item");
update.CommandText = $"UPDATE facts SET missing_checked_at = 0 WHERE item_id IN ({idParams});";
update.ExecuteNonQuery();
}
tx.Commit();
return deleted;
}
public int CleanupUnreferencedAssetFiles()
{
if (!Directory.Exists(_assetsDir))
return 0;
var referenced = GetReferencedLocalAssetPaths();
var deleted = 0;
foreach (var file in Directory.EnumerateFiles(_assetsDir, "*", SearchOption.AllDirectories))
{
var full = Path.GetFullPath(file);
if (referenced.Contains(full))
continue;
File.Delete(file);
deleted++;
}
foreach (var dir in Directory.EnumerateDirectories(_assetsDir, "*", SearchOption.AllDirectories).OrderByDescending(d => d.Length))
{
if (!Directory.EnumerateFileSystemEntries(dir).Any())
Directory.Delete(dir);
}
return deleted;
}
private HashSet<string> GetReferencedLocalAssetPaths()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT path FROM assets;";
using var reader = cmd.ExecuteReader();
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
var path = reader.GetString(0);
if (TryNormalizeLocalAssetPath(path, out var fullPath))
result.Add(fullPath);
}
return result;
}
public bool TryNormalizeLocalAssetPath(string path, out string fullPath)
{
fullPath = string.Empty;
if (string.IsNullOrWhiteSpace(path) || Uri.TryCreate(path, UriKind.Absolute, out _))
return false;
var relative = path;
if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase))
relative = relative[LocalAssetUrlPrefix.Length..];
else if (Path.IsPathRooted(relative))
return false;
var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative));
var root = Path.GetFullPath(_assetsDir) + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase))
return false;
fullPath = candidate;
return true;
} }
public long GetLastScanStarted() public long GetLastScanStarted()
@@ -421,6 +586,68 @@ ON CONFLICT(item_id, lang, kind) DO UPDATE SET
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
public string? GetAssetPath(string itemId, string lang, string kind)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT path FROM assets WHERE item_id = $item_id AND lang = $lang AND kind = $kind;";
cmd.Parameters.AddWithValue("$item_id", itemId);
cmd.Parameters.AddWithValue("$lang", lang);
cmd.Parameters.AddWithValue("$kind", kind);
return cmd.ExecuteScalar() as string;
}
public AssetPresence GetAssetPresence(string itemId, IEnumerable<string> languages)
{
var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (langs.Length == 0)
return new AssetPresence(false, false);
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var langParams = AddParams(cmd, langs, "$lang");
cmd.CommandText = $"SELECT path_low FROM assets WHERE item_id = $item_id AND lang IN ({langParams});";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
var any = false;
var anyRemote = false;
while (reader.Read())
{
any = true;
var path = reader.GetString(0);
if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || path.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
anyRemote = true;
}
return new AssetPresence(any, anyRemote);
}
public bool HasMissingConfiguredTranslations(string itemId, IEnumerable<string> languages)
{
var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (langs.Length == 0)
return false;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var langParams = AddParams(cmd, langs, "$lang");
cmd.CommandText = $@"
SELECT lang, COUNT(DISTINCT field)
FROM translations
WHERE item_id = $item_id AND lang IN ({langParams}) AND field IN ('title', 'overview', 'tagline')
GROUP BY lang;";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
counts[reader.GetString(0)] = reader.GetInt32(1);
return langs.Any(lang => !counts.TryGetValue(lang, out var count) || count < 3);
}
public void UpsertGenre(int tmdbId, string media, string lang, string name) public void UpsertGenre(int tmdbId, string media, string lang, string name)
{ {
if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name)) if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name))
@@ -623,6 +850,243 @@ ON CONFLICT(user_id) DO UPDATE SET langs_json = excluded.langs_json, updated_at
return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<UserRulesDocument>(json); return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<UserRulesDocument>(json);
} }
public Dictionary<string, UserRulesDocument> GetAllUserRules()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT user_id, rules_json FROM user_rules ORDER BY user_id;";
using var reader = cmd.ExecuteReader();
var result = new Dictionary<string, UserRulesDocument>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
var userId = reader.GetString(0);
var json = reader.GetString(1);
var rules = JsonSerializer.Deserialize<UserRulesDocument>(json);
if (rules is not null)
result[userId] = rules;
}
return result;
}
public void ExportTranslationDatabase(string destinationPath)
{
if (File.Exists(destinationPath))
File.Delete(destinationPath);
using var destination = new SqliteConnection($"Data Source={destinationPath}");
destination.Open();
using (var schema = destination.CreateCommand())
{
schema.CommandText = SchemaSql;
schema.ExecuteNonQuery();
}
using var source = Open();
source.Open();
CopyTable(source, destination, "facts", "item_id, tmdb_id, kind, original_title, original_language, original_language_all, origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at");
CopyTable(source, destination, "translations", "item_id, lang, field, text");
CopyTable(source, destination, "assets", "item_id, lang, kind, path, path_low, updated_at");
CopyTable(source, destination, "genres", "tmdb_id, media, lang, name, name_norm");
CopyTable(source, destination, "scan_state", "id, last_scan_started");
}
public ImportDatabaseResult ImportTranslationDatabase(string sourcePath, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
{
var allowed = allowedLanguages.ToHashSet(StringComparer.OrdinalIgnoreCase);
allowed.Add("Original");
using var destination = Open();
destination.Open();
using var source = new SqliteConnection($"Data Source={sourcePath};Mode=ReadOnly");
source.Open();
using var tx = destination.BeginTransaction();
var facts = ImportFacts(source, destination, tx, liveItemIds);
var translations = ImportTranslations(source, destination, tx, liveItemIds, allowed);
var assets = ImportAssets(source, destination, tx, liveItemIds, allowed);
var genres = ImportGenres(source, destination, tx, allowed);
tx.Commit();
return new ImportDatabaseResult(facts, translations, assets, genres);
}
private static void CopyTable(SqliteConnection source, SqliteConnection destination, string table, string columns)
{
using var read = source.CreateCommand();
read.CommandText = $"SELECT {columns} FROM {table};";
using var reader = read.ExecuteReader();
using var tx = destination.BeginTransaction();
while (reader.Read())
{
using var insert = destination.CreateCommand();
insert.Transaction = tx;
var names = columns.Split(',', StringSplitOptions.TrimEntries);
insert.CommandText = $"INSERT OR REPLACE INTO {table}({columns}) VALUES({string.Join(",", names.Select((_, i) => "$v" + i.ToString(CultureInfo.InvariantCulture)))});";
for (var i = 0; i < names.Length; i++)
insert.Parameters.AddWithValue("$v" + i.ToString(CultureInfo.InvariantCulture), reader.GetValue(i));
insert.ExecuteNonQuery();
}
tx.Commit();
}
private static int ImportFacts(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> liveItemIds)
{
using var read = source.CreateCommand();
read.CommandText = @"
SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at
FROM facts;";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var itemId = reader.GetString(0);
if (!liveItemIds.Contains(itemId))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at)
VALUES($item_id, $tmdb_id, $kind, $original_title, $original_language, $original_language_all,
$origin_countries_json, $production_countries_json, $spoken_languages_json, $audio_track_language,
$genre_tmdb_ids_json, $missing_checked_at, $full_checked_at)
ON CONFLICT(item_id) DO UPDATE SET
tmdb_id = excluded.tmdb_id,
kind = excluded.kind,
original_title = excluded.original_title,
original_language = excluded.original_language,
original_language_all = excluded.original_language_all,
origin_countries_json = excluded.origin_countries_json,
production_countries_json = excluded.production_countries_json,
spoken_languages_json = excluded.spoken_languages_json,
audio_track_language = excluded.audio_track_language,
genre_tmdb_ids_json = excluded.genre_tmdb_ids_json,
missing_checked_at = excluded.missing_checked_at,
full_checked_at = excluded.full_checked_at;";
var names = new[]
{
"$item_id",
"$tmdb_id",
"$kind",
"$original_title",
"$original_language",
"$original_language_all",
"$origin_countries_json",
"$production_countries_json",
"$spoken_languages_json",
"$audio_track_language",
"$genre_tmdb_ids_json",
"$missing_checked_at",
"$full_checked_at"
};
for (var i = 0; i < names.Length; i++)
insert.Parameters.AddWithValue(names[i], reader.GetValue(i));
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static int ImportTranslations(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
{
using var read = source.CreateCommand();
read.CommandText = "SELECT item_id, lang, field, text FROM translations;";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var itemId = reader.GetString(0);
var lang = reader.GetString(1);
if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO translations(item_id, lang, field, text)
VALUES($item_id, $lang, $field, $text)
ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;";
insert.Parameters.AddWithValue("$item_id", itemId);
insert.Parameters.AddWithValue("$lang", lang);
insert.Parameters.AddWithValue("$field", reader.GetString(2));
insert.Parameters.AddWithValue("$text", reader.GetString(3));
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static int ImportAssets(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
{
using var read = source.CreateCommand();
read.CommandText = "SELECT item_id, lang, kind, path, path_low, updated_at FROM assets;";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var itemId = reader.GetString(0);
var lang = reader.GetString(1);
if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at)
VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at)
ON CONFLICT(item_id, lang, kind) DO UPDATE SET
path = excluded.path,
path_low = excluded.path_low,
updated_at = excluded.updated_at;";
insert.Parameters.AddWithValue("$item_id", itemId);
insert.Parameters.AddWithValue("$lang", lang);
insert.Parameters.AddWithValue("$kind", reader.GetString(2));
insert.Parameters.AddWithValue("$path", reader.GetString(3));
insert.Parameters.AddWithValue("$path_low", reader.GetString(4));
insert.Parameters.AddWithValue("$updated_at", reader.GetInt64(5));
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static int ImportGenres(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> allowedLanguages)
{
using var read = source.CreateCommand();
read.CommandText = "SELECT tmdb_id, media, lang, name, name_norm FROM genres;";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var lang = reader.GetString(2);
if (!allowedLanguages.Contains(lang))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO genres(tmdb_id, media, lang, name, name_norm)
VALUES($tmdb_id, $media, $lang, $name, $name_norm)
ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET
name = excluded.name,
name_norm = excluded.name_norm;";
insert.Parameters.AddWithValue("$tmdb_id", reader.GetInt32(0));
insert.Parameters.AddWithValue("$media", reader.GetString(1));
insert.Parameters.AddWithValue("$lang", lang);
insert.Parameters.AddWithValue("$name", reader.GetString(3));
insert.Parameters.AddWithValue("$name_norm", reader.GetString(4));
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static string AddParams(SqliteCommand cmd, IReadOnlyList<string> values, string prefix) private static string AddParams(SqliteCommand cmd, IReadOnlyList<string> values, string prefix)
{ {
var names = new string[values.Count]; var names = new string[values.Count];
@@ -639,6 +1103,12 @@ ON CONFLICT(user_id) DO UPDATE SET langs_json = excluded.langs_json, updated_at
public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt); public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt);
public readonly record struct AssetPresence(bool Any, bool AnyRemote);
public readonly record struct LocalCleanupResult(int TranslationsDeleted, int AssetsDeleted, int GenresDeleted, int AssetFilesDeleted);
public readonly record struct ImportDatabaseResult(int Facts, int Translations, int Assets, int Genres);
public readonly record struct GenreRow(int TmdbId, string Media, string Lang, string Name); public readonly record struct GenreRow(int TmdbId, string Media, string Lang, string Name);
public sealed record FactsData( public sealed record FactsData(
@@ -6,9 +6,9 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<Version>0.1.0</Version> <Version>0.2.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion> <AssemblyVersion>0.2.0.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion> <FileVersion>0.2.0.0</FileVersion>
<Authors>ajp_anton</Authors> <Authors>ajp_anton</Authors>
</PropertyGroup> </PropertyGroup>
+18
View File
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Jellyfin.Plugin.Multilang.Configuration; using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Services.Injection;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
@@ -21,6 +22,23 @@ public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
public override Guid Id => Guid.Parse("9d7c61a5-7a1b-4d1e-9b79-4c8c6d2e8a2a"); public override Guid Id => Guid.Parse("9d7c61a5-7a1b-4d1e-9b79-4c8c6d2e8a2a");
public override void OnUninstalling()
{
WebScriptInjector.RemoveInjected(ApplicationPaths.WebPath);
if (Configuration.CleanupDataOnUninstall)
{
var dataDir = Path.Combine(ApplicationPaths.DataPath, "multilang");
if (Directory.Exists(dataDir))
Directory.Delete(dataDir, recursive: true);
if (File.Exists(ConfigurationFilePath))
File.Delete(ConfigurationFilePath);
}
base.OnUninstalling();
}
public IEnumerable<PluginPageInfo> GetPages() public IEnumerable<PluginPageInfo> GetPages()
{ {
yield return new PluginPageInfo yield return new PluginPageInfo
@@ -1,6 +1,8 @@
using Jellyfin.Plugin.Multilang.Data; using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.ScheduledTasks; using Jellyfin.Plugin.Multilang.ScheduledTasks;
using Jellyfin.Plugin.Multilang.Services; 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.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh; using Jellyfin.Plugin.Multilang.Services.Refresh;
using MediaBrowser.Controller; using MediaBrowser.Controller;
@@ -24,6 +26,8 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
services.AddSingleton<RefreshCoordinator>(); services.AddSingleton<RefreshCoordinator>();
services.AddSingleton<RefreshService>(); services.AddSingleton<RefreshService>();
services.AddSingleton<ItemsProxyCache>(); services.AddSingleton<ItemsProxyCache>();
services.AddSingleton<AssetStorageService>();
services.AddSingleton<MultilangBackupService>();
services.AddSingleton<IScheduledTask, InjectStartupTask>(); services.AddSingleton<IScheduledTask, InjectStartupTask>();
services.AddSingleton<IScheduledTask, RefreshMissingTask>(); services.AddSingleton<IScheduledTask, RefreshMissingTask>();
@@ -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; 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) private static string RemoveOldBlock(string content)
{ {
var start = content.IndexOf(StartComment, StringComparison.Ordinal); var start = content.IndexOf(StartComment, StringComparison.Ordinal);
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Multilang.Configuration; using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data; using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
using Jellyfin.Plugin.Multilang.Services.Providers; using Jellyfin.Plugin.Multilang.Services.Providers;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@@ -18,6 +19,7 @@ public sealed class RefreshService
private readonly TranslationStore _store; private readonly TranslationStore _store;
private readonly TmdbClient _tmdbClient; private readonly TmdbClient _tmdbClient;
private readonly FanartClient _fanartClient; private readonly FanartClient _fanartClient;
private readonly AssetStorageService _assetStorage;
private readonly RefreshCoordinator _coordinator; private readonly RefreshCoordinator _coordinator;
private readonly ILogger<RefreshService> _logger; private readonly ILogger<RefreshService> _logger;
private readonly object _diagnosticsLock = new(); private readonly object _diagnosticsLock = new();
@@ -28,6 +30,7 @@ public sealed class RefreshService
TranslationStore store, TranslationStore store,
TmdbClient tmdbClient, TmdbClient tmdbClient,
FanartClient fanartClient, FanartClient fanartClient,
AssetStorageService assetStorage,
RefreshCoordinator coordinator, RefreshCoordinator coordinator,
ILogger<RefreshService> logger) ILogger<RefreshService> logger)
{ {
@@ -35,6 +38,7 @@ public sealed class RefreshService
_store = store; _store = store;
_tmdbClient = tmdbClient; _tmdbClient = tmdbClient;
_fanartClient = fanartClient; _fanartClient = fanartClient;
_assetStorage = assetStorage;
_coordinator = coordinator; _coordinator = coordinator;
_logger = logger; _logger = logger;
} }
@@ -156,7 +160,19 @@ public sealed class RefreshService
var trackedBefore = _store.GetTrackedItemIds(); var trackedBefore = _store.GetTrackedItemIds();
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count(); deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
newCount = liveIds.Except(trackedBefore, 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); _logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
var index = 0; var index = 0;
@@ -168,9 +184,10 @@ public sealed class RefreshService
SetCurrent(item, index, "checking"); SetCurrent(item, index, "checking");
var status = _store.GetFactsStatus(item.ItemId); var status = _store.GetFactsStatus(item.ItemId);
var isNew = !status.Exists; var isNew = !status.Exists;
var missingConfiguredData = jobType == RefreshJobType.Missing && status.Exists && ItemNeedsMissingRefresh(item, cfg, langs: null);
var due = jobType == RefreshJobType.Full var due = jobType == RefreshJobType.Full
? !status.Exists || status.FullCheckedAt < fullCutoff ? !status.Exists || status.FullCheckedAt < fullCutoff
: !status.Exists || status.MissingCheckedAt < missingCutoff; : !status.Exists || status.MissingCheckedAt < missingCutoff || missingConfiguredData;
if (!due) if (!due)
{ {
@@ -304,6 +321,8 @@ public sealed class RefreshService
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds) 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; var cfg = Plugin.Instance?.Configuration;
if (cfg is null) if (cfg is null)
return false; return false;
@@ -530,9 +549,9 @@ public sealed class RefreshService
if (tmdbImages is null) if (tmdbImages is null)
continue; continue;
StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written); await StoreImageMapAsync(item.ItemId, languages, "poster", tmdbImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written); await StoreImageMapAsync(item.ItemId, languages, "logo", tmdbImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written); 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)); Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
continue; continue;
} }
@@ -543,22 +562,24 @@ public sealed class RefreshService
if (fanartImages is null) if (fanartImages is null)
continue; continue;
StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written); await StoreImageMapAsync(item.ItemId, languages, "poster", fanartImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written); await StoreImageMapAsync(item.ItemId, languages, "logo", fanartImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written); await StoreImageMapAsync(item.ItemId, languages, "banner", fanartImages.Banners, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written); 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)); 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, string itemId,
IEnumerable<string> languages, IEnumerable<string> languages,
string kind, string kind,
IReadOnlyDictionary<string, string> images, IReadOnlyDictionary<string, string> images,
PluginConfiguration cfg,
long updatedAt, long updatedAt,
HashSet<string> written) HashSet<string> written,
CancellationToken cancellationToken)
{ {
foreach (var lang in languages) foreach (var lang in languages)
{ {
@@ -573,11 +594,25 @@ public sealed class RefreshService
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url)) if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
continue; 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); 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) private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
=> languages => languages
.Concat([OriginalLanguageTag + ":" + originalLanguage]) .Concat([OriginalLanguageTag + ":" + originalLanguage])
@@ -743,6 +778,9 @@ public sealed class RefreshService
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
private static bool IsLocalAssetStorage(PluginConfiguration cfg)
=> string.Equals(cfg.AssetStorageMode, "local", StringComparison.OrdinalIgnoreCase);
private static string FirstNonEmpty(params string?[] values) private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty; => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
} }