Add backup export and local artwork storage

This commit is contained in:
ajp_anton
2026-06-08 04:19:08 +00:00
parent 63dbc3a6d4
commit a4bb9a98c2
14 changed files with 1745 additions and 46 deletions
@@ -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();