Add backup export and local artwork storage
This commit is contained in:
@@ -7,6 +7,8 @@ using Jellyfin.Plugin.Multilang.Configuration;
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using Jellyfin.Plugin.Multilang.Rules;
|
||||
using Jellyfin.Plugin.Multilang.Services;
|
||||
using Jellyfin.Plugin.Multilang.Services.Assets;
|
||||
using Jellyfin.Plugin.Multilang.Services.Backup;
|
||||
using Jellyfin.Plugin.Multilang.Services.Providers;
|
||||
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
@@ -14,6 +16,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -44,6 +47,8 @@ public sealed class MultilangController : ControllerBase
|
||||
private readonly RefreshService _refreshService;
|
||||
private readonly FanartClient _fanartClient;
|
||||
private readonly ItemsProxyCache _itemsProxyCache;
|
||||
private readonly AssetStorageService _assetStorage;
|
||||
private readonly MultilangBackupService _backupService;
|
||||
private readonly ILogger<MultilangController> _logger;
|
||||
|
||||
public MultilangController(
|
||||
@@ -59,6 +64,8 @@ public sealed class MultilangController : ControllerBase
|
||||
RefreshService refreshService,
|
||||
FanartClient fanartClient,
|
||||
ItemsProxyCache itemsProxyCache,
|
||||
AssetStorageService assetStorage,
|
||||
MultilangBackupService backupService,
|
||||
ILogger<MultilangController> logger)
|
||||
{
|
||||
_store = store;
|
||||
@@ -73,6 +80,8 @@ public sealed class MultilangController : ControllerBase
|
||||
_refreshService = refreshService;
|
||||
_fanartClient = fanartClient;
|
||||
_itemsProxyCache = itemsProxyCache;
|
||||
_assetStorage = assetStorage;
|
||||
_backupService = backupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -88,6 +97,12 @@ public sealed class MultilangController : ControllerBase
|
||||
public IActionResult SharedCss()
|
||||
=> ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css");
|
||||
|
||||
[HttpGet("Assets/{**path}")]
|
||||
public IActionResult Asset(string path)
|
||||
=> _assetStorage.TryResolveLocalAsset(path, out var fullPath, out var contentType)
|
||||
? PhysicalFile(fullPath, contentType)
|
||||
: NotFound();
|
||||
|
||||
[HttpGet("Providers")]
|
||||
public IActionResult Providers()
|
||||
=> Ok(_providerCatalog.Providers);
|
||||
@@ -127,6 +142,7 @@ public sealed class MultilangController : ControllerBase
|
||||
|
||||
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
|
||||
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
|
||||
!string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.OrdinalIgnoreCase) ||
|
||||
!ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries);
|
||||
|
||||
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
|
||||
@@ -963,6 +979,154 @@ public sealed class MultilangController : ControllerBase
|
||||
return Ok(_refreshService.GetDiagnostics());
|
||||
}
|
||||
|
||||
[HttpGet("ExportInfo")]
|
||||
public async Task<IActionResult> ExportInfo()
|
||||
{
|
||||
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
||||
return Unauthorized(new { Error = "AdminRequired" });
|
||||
|
||||
var storage = _backupService.GetStorageInfo();
|
||||
return Ok(new
|
||||
{
|
||||
storage.DatabaseBytes,
|
||||
storage.AssetsBytes,
|
||||
CleanupDataOnUninstall = Plugin.Instance?.Configuration?.CleanupDataOnUninstall == true,
|
||||
AssetStorageMode = Plugin.Instance?.Configuration?.AssetStorageMode ?? "url"
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("Export")]
|
||||
public async Task<IActionResult> Export(
|
||||
[FromQuery] bool pluginSettings = true,
|
||||
[FromQuery] bool userSettings = true,
|
||||
[FromQuery] bool translationsDatabase = false,
|
||||
[FromQuery] bool downloadedAssets = false)
|
||||
{
|
||||
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
||||
return Unauthorized(new { Error = "AdminRequired" });
|
||||
|
||||
if (downloadedAssets && !translationsDatabase)
|
||||
return BadRequest(new { Error = "AssetsRequireDatabase" });
|
||||
|
||||
var bytes = _backupService.Export(
|
||||
new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets),
|
||||
Plugin.Instance?.Configuration ?? new PluginConfiguration());
|
||||
var name = "multilang-export-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".zip";
|
||||
return File(bytes, "application/zip", name);
|
||||
}
|
||||
|
||||
[HttpPost("Import")]
|
||||
public async Task<IActionResult> Import()
|
||||
{
|
||||
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
||||
return Unauthorized(new { Error = "AdminRequired" });
|
||||
|
||||
var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
var file = form.Files.GetFile("file");
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { Error = "MissingFile" });
|
||||
|
||||
var options = new BackupImportOptions(
|
||||
FormBool(form, "pluginSettings", true),
|
||||
FormBool(form, "userSettings", true),
|
||||
FormBool(form, "translationsDatabase", true),
|
||||
FormBool(form, "downloadedAssets", true));
|
||||
if (options.DownloadedAssets && !options.TranslationsDatabase)
|
||||
return BadRequest(new { Error = "AssetsRequireDatabase" });
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = _backupService.ImportAdmin(
|
||||
stream,
|
||||
options,
|
||||
Plugin.Instance?.Configuration ?? new PluginConfiguration(),
|
||||
cfg => Plugin.Instance?.UpdateConfiguration(cfg));
|
||||
_itemsProxyCache.ClearAll();
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("CleanupAll")]
|
||||
public async Task<IActionResult> CleanupAll([FromBody] CleanupAllRequest request)
|
||||
{
|
||||
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
||||
return Unauthorized(new { Error = "AdminRequired" });
|
||||
|
||||
if (request.Confirm != true)
|
||||
return BadRequest(new { Error = "ConfirmationRequired" });
|
||||
|
||||
Plugin.Instance?.UpdateConfiguration(new PluginConfiguration());
|
||||
_store.ResetAll();
|
||||
_itemsProxyCache.ClearAll();
|
||||
return Ok(new { Cleared = true });
|
||||
}
|
||||
|
||||
[HttpGet("UserRules/self/export")]
|
||||
public async Task<IActionResult> ExportSelfRules()
|
||||
{
|
||||
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized(new { Error = "NotAuthenticated" });
|
||||
|
||||
var bytes = _backupService.ExportUserRules(userId, GetRulesOrDefault(userId));
|
||||
var name = "multilang-user-rules-" + userId + ".zip";
|
||||
return File(bytes, "application/zip", name);
|
||||
}
|
||||
|
||||
[HttpPost("UserRules/self/import/inspect")]
|
||||
public async Task<IActionResult> InspectSelfRulesImport()
|
||||
{
|
||||
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized(new { Error = "NotAuthenticated" });
|
||||
|
||||
var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
var file = form.Files.GetFile("file");
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { Error = "MissingFile" });
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
return Ok(_backupService.InspectUserImport(stream, userId));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("UserRules/self/import")]
|
||||
public async Task<IActionResult> ImportSelfRules()
|
||||
{
|
||||
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized(new { Error = "NotAuthenticated" });
|
||||
|
||||
var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
var file = form.Files.GetFile("file");
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { Error = "MissingFile" });
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = _backupService.ImportUser(stream, userId, form.TryGetValue("sourceUserId", out var sourceUserId) ? sourceUserId.ToString() : null);
|
||||
if (result.UserSettingsImported > 0)
|
||||
_itemsProxyCache.ClearAll();
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("Debug/{itemId}")]
|
||||
public async Task<IActionResult> DebugItem(string itemId)
|
||||
{
|
||||
@@ -1989,6 +2153,16 @@ public sealed class MultilangController : ControllerBase
|
||||
return dto.Policy?.IsAdministrator == true;
|
||||
}
|
||||
|
||||
private static bool FormBool(IFormCollection form, string key, bool fallback)
|
||||
{
|
||||
if (!form.TryGetValue(key, out var values))
|
||||
return fallback;
|
||||
var value = values.ToString();
|
||||
return value.Equals("1", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("on", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryGetIsAdmin(object user, out bool isAdmin)
|
||||
{
|
||||
isAdmin = false;
|
||||
@@ -2063,6 +2237,8 @@ public sealed class MultilangController : ControllerBase
|
||||
|
||||
public sealed record TestProviderRequest(string Provider, string ApiKey);
|
||||
|
||||
public sealed record CleanupAllRequest(bool Confirm);
|
||||
|
||||
public sealed class AdminConfigDto
|
||||
{
|
||||
public string TmdbApiKey { get; set; } = string.Empty;
|
||||
@@ -2083,12 +2259,16 @@ public sealed class AdminConfigDto
|
||||
|
||||
public int ItemsProxyCacheMaxMiB { get; set; }
|
||||
|
||||
public string AssetStorageMode { get; set; } = "url";
|
||||
|
||||
public SortArticleEntry[] ArticleEntries { get; set; } = [];
|
||||
|
||||
public bool EnableLogging { get; set; }
|
||||
|
||||
public bool VerboseLogging { get; set; }
|
||||
|
||||
public bool CleanupDataOnUninstall { get; set; }
|
||||
|
||||
public static AdminConfigDto FromConfiguration(PluginConfiguration cfg)
|
||||
=> new()
|
||||
{
|
||||
@@ -2101,9 +2281,11 @@ public sealed class AdminConfigDto
|
||||
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
|
||||
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
|
||||
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
|
||||
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
|
||||
ArticleEntries = cfg.ArticleEntries,
|
||||
EnableLogging = cfg.EnableLogging,
|
||||
VerboseLogging = cfg.VerboseLogging
|
||||
VerboseLogging = cfg.VerboseLogging,
|
||||
CleanupDataOnUninstall = cfg.CleanupDataOnUninstall
|
||||
};
|
||||
|
||||
public PluginConfiguration ToConfiguration(ProviderCatalog providerCatalog)
|
||||
@@ -2121,12 +2303,14 @@ public sealed class AdminConfigDto
|
||||
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
|
||||
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
|
||||
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
|
||||
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
|
||||
ArticleEntries = ArticleEntries
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Language))
|
||||
.Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply })
|
||||
.ToArray(),
|
||||
EnableLogging = EnableLogging || VerboseLogging,
|
||||
VerboseLogging = VerboseLogging
|
||||
VerboseLogging = VerboseLogging,
|
||||
CleanupDataOnUninstall = CleanupDataOnUninstall
|
||||
};
|
||||
|
||||
private static string[] NormalizeLanguages(IEnumerable<string> languages)
|
||||
@@ -2134,4 +2318,7 @@ public sealed class AdminConfigDto
|
||||
.Where(l => l.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static string NormalizeAssetStorageMode(string? value)
|
||||
=> string.Equals(value?.Trim(), "local", StringComparison.OrdinalIgnoreCase) ? "local" : "url";
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class PluginConfiguration : BasePluginConfiguration
|
||||
|
||||
public int ItemsProxyCacheMaxMiB { get; set; } = 50;
|
||||
|
||||
public string AssetStorageMode { get; set; } = "url";
|
||||
|
||||
[XmlArray("SortArticles")]
|
||||
[XmlArrayItem("SortArticleEntry")]
|
||||
public SortArticleEntry[] ArticleEntries { get; set; } = [];
|
||||
@@ -34,6 +36,8 @@ public sealed class PluginConfiguration : BasePluginConfiguration
|
||||
public bool EnableLogging { get; set; }
|
||||
|
||||
public bool VerboseLogging { get; set; }
|
||||
|
||||
public bool CleanupDataOnUninstall { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProviderState
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
|
||||
<fieldset class="ml-section">
|
||||
<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">
|
||||
<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">
|
||||
@@ -105,12 +113,68 @@
|
||||
</div>
|
||||
</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-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
(function () {
|
||||
@@ -297,6 +361,8 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
state.config[input.dataset.key] = input.value;
|
||||
});
|
||||
@@ -316,8 +382,10 @@
|
||||
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0);
|
||||
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").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.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) {
|
||||
return {
|
||||
Language: normalizeLang(row.querySelector(".ml-articles-lang").value),
|
||||
@@ -325,6 +393,7 @@
|
||||
AlwaysApply: row.querySelector(".ml-articles-apply-toggle").checked
|
||||
};
|
||||
}).filter(function (entry) { return entry.Language; });
|
||||
state.config._previousAssetStorageMode = previousStorageMode;
|
||||
return state.config;
|
||||
}
|
||||
|
||||
@@ -335,13 +404,16 @@
|
||||
document.getElementById("ml-cache-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000;
|
||||
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
|
||||
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-verbose").checked = !!state.config.VerboseLogging;
|
||||
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
|
||||
renderProviderInputs();
|
||||
renderProviderBuckets();
|
||||
renderArticles();
|
||||
loadCacheDiagnostics();
|
||||
loadRefreshDiagnostics();
|
||||
loadExportInfo();
|
||||
}
|
||||
|
||||
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() {
|
||||
M.setStatus(status, "Loading...");
|
||||
try {
|
||||
@@ -692,7 +871,17 @@
|
||||
event.preventDefault();
|
||||
M.setStatus(status, "Saving...");
|
||||
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();
|
||||
M.setStatus(status, "Saved.");
|
||||
} catch (err) {
|
||||
@@ -700,9 +889,21 @@
|
||||
}
|
||||
});
|
||||
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-clear").addEventListener("click", clearCacheEntries);
|
||||
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 () {
|
||||
if (!this.checked) document.getElementById("ml-log-verbose").checked = false;
|
||||
});
|
||||
|
||||
@@ -36,6 +36,42 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: .8rem;
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
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) {
|
||||
const response = await fetch(`${basePath()}${path}`, {
|
||||
cache: "no-store",
|
||||
@@ -89,6 +101,8 @@
|
||||
window.Multilang = {
|
||||
get: (path) => request(path),
|
||||
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
basePath,
|
||||
authHeaders,
|
||||
splitList,
|
||||
setStatus,
|
||||
chip,
|
||||
|
||||
@@ -31,12 +31,37 @@
|
||||
<div id="ml-action-matrix" class="ml-action-matrix"></div>
|
||||
</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-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
(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() {
|
||||
if (page.dataset.bound === "1") return;
|
||||
page.dataset.bound = "1";
|
||||
@@ -1147,6 +1269,17 @@
|
||||
});
|
||||
$("ml-reset-user").addEventListener("click", load);
|
||||
$("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); });
|
||||
$("ml-add-category").addEventListener("click", function () {
|
||||
syncCategoriesFromDom();
|
||||
|
||||
@@ -9,35 +9,9 @@ namespace Jellyfin.Plugin.Multilang.Data;
|
||||
|
||||
public sealed class TranslationStore
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
public const string LocalAssetUrlPrefix = "/Multilang/Assets/";
|
||||
|
||||
public TranslationStore(IApplicationPaths appPaths)
|
||||
{
|
||||
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 = @"
|
||||
private const string SchemaSql = @"
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
item_id TEXT PRIMARY KEY,
|
||||
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);
|
||||
";
|
||||
|
||||
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();
|
||||
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)
|
||||
{
|
||||
using (var info = con.CreateCommand())
|
||||
@@ -173,6 +207,137 @@ DELETE FROM assets WHERE item_id = $item_id;";
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -421,6 +586,68 @@ ON CONFLICT(item_id, lang, kind) DO UPDATE SET
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 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 sealed record FactsData(
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<Version>0.1.0</Version>
|
||||
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||
<FileVersion>0.1.0.0</FileVersion>
|
||||
<Version>0.2.0</Version>
|
||||
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
||||
<FileVersion>0.2.0.0</FileVersion>
|
||||
<Authors>ajp_anton</Authors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Multilang.Configuration;
|
||||
using Jellyfin.Plugin.Multilang.Services.Injection;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.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 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()
|
||||
{
|
||||
yield return new PluginPageInfo
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using Jellyfin.Plugin.Multilang.ScheduledTasks;
|
||||
using Jellyfin.Plugin.Multilang.Services;
|
||||
using Jellyfin.Plugin.Multilang.Services.Assets;
|
||||
using Jellyfin.Plugin.Multilang.Services.Backup;
|
||||
using Jellyfin.Plugin.Multilang.Services.Providers;
|
||||
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -24,6 +26,8 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
services.AddSingleton<RefreshCoordinator>();
|
||||
services.AddSingleton<RefreshService>();
|
||||
services.AddSingleton<ItemsProxyCache>();
|
||||
services.AddSingleton<AssetStorageService>();
|
||||
services.AddSingleton<MultilangBackupService>();
|
||||
|
||||
services.AddSingleton<IScheduledTask, InjectStartupTask>();
|
||||
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;
|
||||
}
|
||||
|
||||
public static bool RemoveInjected(string webRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(webRootPath))
|
||||
return false;
|
||||
|
||||
var indexPath = Path.Combine(webRootPath, "index.html");
|
||||
if (!File.Exists(indexPath))
|
||||
return false;
|
||||
|
||||
var content = File.ReadAllText(indexPath);
|
||||
var cleaned = RemoveOldBlock(content);
|
||||
if (cleaned.Equals(content, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
File.WriteAllText(indexPath, cleaned, Encoding.UTF8);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string RemoveOldBlock(string content)
|
||||
{
|
||||
var start = content.IndexOf(StartComment, StringComparison.Ordinal);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Multilang.Configuration;
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using Jellyfin.Plugin.Multilang.Services.Assets;
|
||||
using Jellyfin.Plugin.Multilang.Services.Providers;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -18,6 +19,7 @@ public sealed class RefreshService
|
||||
private readonly TranslationStore _store;
|
||||
private readonly TmdbClient _tmdbClient;
|
||||
private readonly FanartClient _fanartClient;
|
||||
private readonly AssetStorageService _assetStorage;
|
||||
private readonly RefreshCoordinator _coordinator;
|
||||
private readonly ILogger<RefreshService> _logger;
|
||||
private readonly object _diagnosticsLock = new();
|
||||
@@ -28,6 +30,7 @@ public sealed class RefreshService
|
||||
TranslationStore store,
|
||||
TmdbClient tmdbClient,
|
||||
FanartClient fanartClient,
|
||||
AssetStorageService assetStorage,
|
||||
RefreshCoordinator coordinator,
|
||||
ILogger<RefreshService> logger)
|
||||
{
|
||||
@@ -35,6 +38,7 @@ public sealed class RefreshService
|
||||
_store = store;
|
||||
_tmdbClient = tmdbClient;
|
||||
_fanartClient = fanartClient;
|
||||
_assetStorage = assetStorage;
|
||||
_coordinator = coordinator;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -156,7 +160,19 @@ public sealed class RefreshService
|
||||
var trackedBefore = _store.GetTrackedItemIds();
|
||||
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
|
||||
newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count();
|
||||
_store.CleanupNotInLibrary(liveIds);
|
||||
// Run local housekeeping only from full-library scans. ItemsProxy browsing may enqueue
|
||||
// missing items, but it must not delete local assets just because the admin briefly
|
||||
// changed artwork storage mode.
|
||||
var localCleanup = _store.CleanupForConfiguration(liveIds, NormalizeLanguages(cfg.Languages), IsLocalAssetStorage(cfg));
|
||||
if (localCleanup.TranslationsDeleted > 0 || localCleanup.AssetsDeleted > 0 || localCleanup.GenresDeleted > 0 || localCleanup.AssetFilesDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Multilang local cleanup translations={Translations} assets={Assets} genres={Genres} assetFiles={AssetFiles}",
|
||||
localCleanup.TranslationsDeleted,
|
||||
localCleanup.AssetsDeleted,
|
||||
localCleanup.GenresDeleted,
|
||||
localCleanup.AssetFilesDeleted);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
|
||||
var index = 0;
|
||||
@@ -168,9 +184,10 @@ public sealed class RefreshService
|
||||
SetCurrent(item, index, "checking");
|
||||
var status = _store.GetFactsStatus(item.ItemId);
|
||||
var isNew = !status.Exists;
|
||||
var missingConfiguredData = jobType == RefreshJobType.Missing && status.Exists && ItemNeedsMissingRefresh(item, cfg, langs: null);
|
||||
var due = jobType == RefreshJobType.Full
|
||||
? !status.Exists || status.FullCheckedAt < fullCutoff
|
||||
: !status.Exists || status.MissingCheckedAt < missingCutoff;
|
||||
: !status.Exists || status.MissingCheckedAt < missingCutoff || missingConfiguredData;
|
||||
|
||||
if (!due)
|
||||
{
|
||||
@@ -304,6 +321,8 @@ public sealed class RefreshService
|
||||
|
||||
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
|
||||
{
|
||||
// Browsing can only backfill completely untracked items. It deliberately does not run
|
||||
// local cleanup or convert existing local assets to URL rows.
|
||||
var cfg = Plugin.Instance?.Configuration;
|
||||
if (cfg is null)
|
||||
return false;
|
||||
@@ -530,9 +549,9 @@ public sealed class RefreshService
|
||||
if (tmdbImages is null)
|
||||
continue;
|
||||
|
||||
StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "poster", tmdbImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "logo", tmdbImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
|
||||
continue;
|
||||
}
|
||||
@@ -543,22 +562,24 @@ public sealed class RefreshService
|
||||
if (fanartImages is null)
|
||||
continue;
|
||||
|
||||
StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written);
|
||||
StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "poster", fanartImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "logo", fanartImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "banner", fanartImages.Banners, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
await StoreImageMapAsync(item.ItemId, languages, "thumb", fanartImages.Thumbs, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
|
||||
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreImageMap(
|
||||
private async Task StoreImageMapAsync(
|
||||
string itemId,
|
||||
IEnumerable<string> languages,
|
||||
string kind,
|
||||
IReadOnlyDictionary<string, string> images,
|
||||
PluginConfiguration cfg,
|
||||
long updatedAt,
|
||||
HashSet<string> written)
|
||||
HashSet<string> written,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var lang in languages)
|
||||
{
|
||||
@@ -573,11 +594,25 @@ public sealed class RefreshService
|
||||
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
|
||||
continue;
|
||||
|
||||
_store.UpsertAsset(itemId, storeLang, kind, url, updatedAt);
|
||||
var storedPath = await _assetStorage.StoreAsync(itemId, storeLang, kind, url, IsLocalAssetStorage(cfg), cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(storedPath))
|
||||
continue;
|
||||
|
||||
_store.UpsertAsset(itemId, storeLang, kind, storedPath, updatedAt);
|
||||
written.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ItemNeedsMissingRefresh(RefreshItemInfo item, PluginConfiguration cfg, string[]? langs)
|
||||
{
|
||||
langs ??= NormalizeLanguages(cfg.Languages);
|
||||
if (_store.HasMissingConfiguredTranslations(item.ItemId, langs))
|
||||
return true;
|
||||
|
||||
var assetPresence = _store.GetAssetPresence(item.ItemId, langs.Append(OriginalLanguageTag));
|
||||
return IsLocalAssetStorage(cfg) && assetPresence.AnyRemote;
|
||||
}
|
||||
|
||||
private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
|
||||
=> languages
|
||||
.Concat([OriginalLanguageTag + ":" + originalLanguage])
|
||||
@@ -743,6 +778,9 @@ public sealed class RefreshService
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static bool IsLocalAssetStorage(PluginConfiguration cfg)
|
||||
=> string.Equals(cfg.AssetStorageMode, "local", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user