const API = {
status: "/api/status",
titles: "/api/titles",
config: "/api/config",
downloads: "/api/downloads",
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
};
const SOURCE_TYPES = {
official: { label: "Official", className: "ok", installAllowed: true, description: "Native console installation" },
external: { label: "External", className: "external", installAllowed: true, description: "Real external installation" },
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Download only" },
unknown: { label: "Unknown", className: "blocked", installAllowed: false, description: "Blocked" },
};
const fallback = {
status: {
firmware: "11.60",
firmware_build: "0x11600000",
dns_guard: "Active",
resolver: "Internal allowlist",
free_space_mb: 771916,
download_dir: "/data/patchdl (internal)",
},
config: {
default_policy: "deny",
download_dir: "/data/patchdl (internal)",
install_after_download: false,
delete_pkg_after_install: true,
verify_downloads: false,
home_shortcut: true,
source_policy: {
official: { allow_check: true, allow_download: true, allow_install: true },
external: { allow_check: true, allow_download: true, allow_install: true },
shadowmount: { allow_check: true, allow_download: true, allow_install: false },
unknown: { allow_check: true, allow_download: false, allow_install: false },
},
cdn_allowlist: [
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net",
],
},
titles: [
{
title_id: "PPSA01628_00", name: "Call of Duty Black Ops Cold War",
content_id: "UP0002-PPSA01628_00-CODCWTHEGAME0001",
installed_version: "01.032.000", compatible_version: "01.041.000",
latest_version: "01.041.000", latest_required_fw: "11.60",
source_type: "official", source_path: "/system_ex/app/PPSA01628_00",
mount_from: "/dev/ssd0.system_ex", enabled: true, status: "available",
},
{
title_id: "PPSA01284_00", name: "Demon's Souls",
content_id: "UP9000-PPSA01284_00-DEMONSSOULS00001",
installed_version: "01.004.000", compatible_version: "01.004.000",
latest_version: "01.004.000", latest_required_fw: "10.01",
source_type: "external", source_path: "/system_data/priv/appmeta/external/PPSA01284_00",
mount_from: "/mnt/ext0/user/app/PPSA01284_00", enabled: true, status: "up_to_date",
},
{
title_id: "PPSA90001_00", name: "Shadowmounted Test Title",
content_id: "UP0000-PPSA90001_00-SHADOWMOUNT0001",
installed_version: "01.000.000", compatible_version: "01.006.000",
latest_version: "01.009.000", latest_required_fw: "12.00",
source_type: "shadowmount", source_path: "/system_ex/app/PPSA90001_00",
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "available",
},
],
downloads: [],
logs: [],
};
let state = {
status: fallback.status,
config: fallback.config,
titles: fallback.titles,
downloads: fallback.downloads,
logs: fallback.logs,
view: "games",
filter: "all",
query: "",
usingFallback: false,
};
let downloadPollTimer = null;
let emptyPolls = 0;
const dlMeta = {}; // per-title speed tracking: { bytes, t, speed }
const els = {};
document.addEventListener("DOMContentLoaded", () => {
bindElements();
bindEvents();
loadInitialData();
});
function bindElements() {
Object.assign(els, {
firmwareValue: document.getElementById("firmwareValue"),
firmwareBuild: document.getElementById("firmwareBuild"),
dnsValue: document.getElementById("dnsValue"),
resolverValue: document.getElementById("resolverValue"),
spaceValue: document.getElementById("spaceValue"),
downloadDirValue: document.getElementById("downloadDirValue"),
railFw: document.getElementById("railFw"),
railSpace: document.getElementById("railSpace"),
gameGrid: document.getElementById("gameGrid"),
logOutput: document.getElementById("logOutput"),
allowlistHosts: document.getElementById("allowlistHosts"),
sourcePolicyList: document.getElementById("sourcePolicyList"),
searchInput: document.getElementById("searchInput"),
defaultPolicy: document.getElementById("defaultPolicy"),
downloadDir: document.getElementById("downloadDir"),
installAfterDownload: document.getElementById("installAfterDownload"),
deleteAfterInstall: document.getElementById("deleteAfterInstall"),
verifyDownloads: document.getElementById("verifyDownloads"),
homeShortcut: document.getElementById("homeShortcut"),
refreshBtn: document.getElementById("refreshBtn"),
saveBtn: document.getElementById("saveBtn"),
clearLogBtn: document.getElementById("clearLogBtn"),
toast: document.getElementById("toast"),
});
}
function bindEvents() {
document.querySelectorAll(".nav-link").forEach((b) =>
b.addEventListener("click", () => setView(b.dataset.view)));
els.searchInput.addEventListener("input", (e) => {
state.query = e.target.value.trim().toLowerCase();
renderGames();
});
document.querySelectorAll(".segmented button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".segmented button").forEach((i) => {
i.classList.remove("is-selected");
i.setAttribute("aria-pressed", "false");
});
button.classList.add("is-selected");
button.setAttribute("aria-pressed", "true");
state.filter = button.dataset.filter;
renderGames();
});
});
els.refreshBtn.addEventListener("click", loadInitialData);
els.saveBtn.addEventListener("click", saveConfig);
els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); });
}
function setView(view) {
state.view = view;
document.querySelectorAll(".nav-link").forEach((b) => {
const on = b.dataset.view === view;
b.classList.toggle("is-active", on);
if (on) b.setAttribute("aria-current", "page");
else b.removeAttribute("aria-current");
});
document.querySelectorAll(".view").forEach((s) => s.classList.toggle("is-active", s.dataset.view === view));
}
async function loadInitialData() {
state.usingFallback = false;
const [status, config, titles, downloads] = await Promise.all([
getJson(API.status, fallback.status),
getJson(API.config, fallback.config),
getJson(API.titles, fallback.titles),
getJson(API.downloads, fallback.downloads),
]);
// /api/titles carries no client-only progress flags, so preserve them across a
// refresh — otherwise an in-flight download/install flips back to a clickable
// button mid-operation.
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
titles.forEach((g) => {
const old = prev.get(g.title_id);
// Carry client-only progress flags across a refresh — unless the server now
// reports the title up to date (the patch applied), in which case drop them.
if (old && g.status !== "up_to_date") {
if (old.downloading) g.downloading = true;
if (old.downloaded) g.downloaded = true;
if (old.installing) g.installing = true;
if (old._localDownloading) g._localDownloading = true;
}
});
// Reconcile active downloads the server reports, so progress + Cancel show even
// after a hard reload or a download started from another session/device.
const activeDl = new Set(downloads.map((d) => d.title_id));
titles.forEach((g) => { if (activeDl.has(g.title_id)) g.downloading = true; });
state = { ...state, status, config, titles, downloads };
render();
if (state.downloads.length) startDownloadPolling();
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
}
async function getJson(url, fallbackValue) {
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
state.usingFallback = true;
return clone(fallbackValue);
}
}
function render() {
renderStatus();
renderSettings();
renderGames();
renderLogs();
}
function renderStatus() {
els.firmwareValue.textContent = state.status.firmware || "--";
els.firmwareBuild.textContent = state.status.firmware_build || "System version";
els.dnsValue.textContent = state.status.dns_guard || "--";
els.resolverValue.textContent = state.status.resolver || "--";
const space = formatMb(state.status.free_space_mb);
els.spaceValue.textContent = space;
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
if (els.railSpace) els.railSpace.textContent = `${space} free`;
}
function renderSettings() {
els.defaultPolicy.value = state.config.default_policy || "deny";
els.downloadDir.value = state.config.download_dir || "";
els.installAfterDownload.checked = Boolean(state.config.install_after_download);
els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
if (els.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads);
if (els.homeShortcut) els.homeShortcut.checked = state.config.home_shortcut !== false;
els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => {
const chip = document.createElement("span");
chip.className = "host-chip";
chip.textContent = host;
return chip;
}));
renderSourcePolicies();
}
/* ---------------- games ---------------- */
function renderGames() {
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
els.gameGrid.replaceChildren();
if (!visible.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "No titles match this filter.";
els.gameGrid.appendChild(empty);
return;
}
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
}
// Mutually-exclusive bucket per game for the filter chips.
function gameCategory(game) {
// checking is transient (version lookup still running); keep it visible under
// Updatable rather than letting it fall out of every specific filter.
if (game.installing || game.downloading || game.status === "checking") return "updatable";
if (game.patch_title_match === false) return "blocked";
if (!sourcePolicy(game).allow_install) return "blocked";
if (game.status === "available") return "updatable";
if (game.status === "incompatible_fw") return "needsfw";
return "uptodate";
}
function matchesFilter(game) {
if (state.filter === "all") return true;
return gameCategory(game) === state.filter;
}
function matchesQuery(game) {
if (!state.query) return true;
return [game.name, game.title_id, game.content_id, game.source_type, game.source_path, game.mount_from]
.filter(Boolean)
.some((v) => v.toLowerCase().includes(state.query));
}
function createGameCard(game) {
const card = document.createElement("article");
card.className = "game-card";
card.dataset.titleId = game.title_id;
if (game.downloading) card.classList.add("is-downloading");
if (game.installing) card.classList.add("is-installing");
// ---- top row: icon (+ toggle) | body | action ----
const row = document.createElement("div");
row.className = "card-row";
const lead = document.createElement("div");
lead.className = "lead";
const cover = document.createElement("div");
cover.className = "cover";
cover.textContent = initials(game.name);
lead.appendChild(cover);
if (sourcePolicy(game).allow_install) lead.appendChild(buildToggle(game));
const body = document.createElement("div");
body.className = "card-body";
body.innerHTML = `
${escapeHtml(game.name)}
${escapeHtml(game.title_id)}
${escapeHtml(game.content_id || "")}
${game.downloading ? `Downloading` : ""}
${statusPill(game)}
${sourcePill(game)}
Installed ${escapeHtml(game.installed_version || "--")}
Compatible ${escapeHtml(game.compatible_version || "None")}
Latest ${escapeHtml(game.latest_version || "--")}${game.latest_required_fw ? ` · FW ${escapeHtml(game.latest_required_fw)}` : ""}
`;
const actions = document.createElement("div");
actions.className = "card-actions";
const act = tileButton(game);
if (act) {
const btn = document.createElement("button");
btn.className = `row-button is-${act.variant}`;
btn.textContent = act.label;
if (act.hint) btn.title = act.hint;
if (act.disabled) btn.disabled = true;
else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
actions.appendChild(btn);
}
// Delete a finished (not-yet-installed) download.
if (game.downloaded && !game.installing && !game.downloading) {
const del = document.createElement("button");
del.className = "row-button is-ghost";
del.textContent = "Delete";
del.title = "Delete the downloaded package";
del.addEventListener("click", () => cancelDownload(game.title_id));
actions.appendChild(del);
}
row.append(lead, body, actions);
card.appendChild(row);
// ---- progress block (only while downloading) ----
if (game.downloading) {
const d = state.downloads.find((x) => x.title_id === game.title_id) || {};
const prog = document.createElement("div");
prog.className = "card-progress";
prog.innerHTML = `
${progressMetaHtml(d)}
`;
card.appendChild(prog);
}
return card;
}
function buildToggle(game) {
const toggle = document.createElement("label");
toggle.className = "toggle";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = game.enabled !== false;
cb.setAttribute("aria-label", `Enable updates for ${game.name}`);
const track = document.createElement("span");
track.className = "track";
const lbl = document.createElement("span");
lbl.className = "label";
lbl.textContent = cb.checked ? "On" : "Off";
cb.addEventListener("change", () => {
lbl.textContent = cb.checked ? "On" : "Off";
updateGame(game.title_id, { enabled: cb.checked });
});
toggle.append(cb, track, lbl);
return toggle;
}
// The single morphing action button: blue Update/Download/Install, or amber
// Cancel while a download runs. Fixed width (CSS) so the label never reflows.
function tileButton(game) {
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
if (game.downloading) return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop the download and delete the partial file" };
if (game.patch_title_match === false) return null;
if (!isInstallAllowed(game)) return null;
if (game.downloaded && game.status === "available")
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
if (game.status !== "available") return null;
return state.config.install_after_download
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
: { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." };
}
function statusPill(game) {
if (game.installing) return `Installing…`;
if (game.status === "checking") return `Checking…`;
if (game.patch_title_match === false) return `Title mismatch`;
if (game.status === "available") return `Update available`;
if (game.status === "incompatible_fw") return `Needs FW ${escapeHtml(game.latest_required_fw || "")}`;
if (game.status === "up_to_date") return `Up to date`;
return `No patch info`;
}
function sourcePill(game) {
const info = sourceInfo(game);
return `${escapeHtml(info.label)}`;
}
function sourceInfo(game) { return SOURCE_TYPES[sourceType(game)] || SOURCE_TYPES.unknown; }
function sourceType(game) { return game.source_type || "unknown"; }
/* ---------------- download progress (in-tile) ---------------- */
function pctOf(d) {
const done = Number(d.bytes) || 0;
const total = Number(d.total_bytes) || 0;
if (total > 0) return Math.max(0, Math.min(100, Math.round((done * 100) / total)));
return Math.max(0, Math.min(100, Number(d.progress) || 0));
}
function progressMetaHtml(d) {
const done = Number(d.bytes) || 0;
const total = Number(d.total_bytes) || 0;
const speed = Number(d._speed) || 0;
const parts = [];
parts.push(`${formatBytes(done)}${total > 0 ? ` / ${formatBytes(total)}` : ""}`);
if (speed > 0) parts.push(`${formatBytes(speed)}/s`);
if (speed > 0 && total > done) parts.push(`ETA ${formatEta((total - done) / speed)}`);
else if (!total) parts.push(`Fetching manifest…`);
parts.push(`${pctOf(d)}%`);
return parts.join("");
}
function startDownloadPolling() {
emptyPolls = 0;
if (downloadPollTimer) return;
downloadPollTimer = setInterval(refreshDownloads, 2000);
refreshDownloads();
}
function stopDownloadPolling() {
if (!downloadPollTimer) return;
clearInterval(downloadPollTimer);
downloadPollTimer = null;
}
async function refreshDownloads() {
let downloads;
try {
const response = await fetch(API.downloads, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
downloads = await response.json();
} catch (error) {
return; // keep last state on a transient failure
}
const now = Date.now();
const activeIds = new Set();
downloads.forEach((d) => {
activeIds.add(d.title_id);
const done = Number(d.bytes) || 0;
const prev = dlMeta[d.title_id];
if (prev && now > prev.t) {
if (done >= prev.bytes) {
const inst = ((done - prev.bytes) * 1000) / (now - prev.t); // bytes/s
prev.speed = prev.speed ? prev.speed * 0.5 + inst * 0.5 : inst; // smoothed
} else {
prev.speed = 0; // counter went backwards -> re-baseline, no stale speed
}
}
const meta = prev || (dlMeta[d.title_id] = { speed: 0 });
meta.bytes = done;
meta.t = now;
d._speed = meta.speed || 0;
});
Object.keys(dlMeta).forEach((id) => { if (!activeIds.has(id)) delete dlMeta[id]; });
state.downloads = downloads;
// Reconcile downloading flags with the server. A structural change (a download
// appeared or finished) needs a full re-render to add/remove the progress block
// and morph the button; otherwise update the bar in place.
const before = downloadingIds();
state.titles.forEach((g) => {
if (activeIds.has(g.title_id)) g.downloading = true;
else if (g.downloading && !g._localDownloading) g.downloading = false;
});
if (downloadingIds() !== before) renderGames();
else applyDownloadProgress();
if (!downloads.length && !state.titles.some((g) => g.downloading)) {
if (++emptyPolls >= 3) stopDownloadPolling();
} else {
emptyPolls = 0;
}
}
function downloadingIds() {
return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(",");
}
// Update the progress bar/meta in place to avoid rebuilding every card each tick.
function applyDownloadProgress() {
let needRender = false;
state.downloads.forEach((d) => {
const card = els.gameGrid.querySelector(`[data-title-id="${d.title_id}"]`);
const bar = card && card.querySelector(".card-progress .progress > i");
const meta = card && card.querySelector(".card-progress .progress-meta");
if (!bar || !meta) { needRender = true; return; }
bar.style.width = pctOf(d) + "%";
meta.innerHTML = progressMetaHtml(d);
});
if (needRender) renderGames();
}
/* ---------------- settings ---------------- */
function renderSourcePolicies() {
const rows = Object.keys(SOURCE_TYPES).map((type) => {
const info = SOURCE_TYPES[type];
const policy = sourcePolicyForType(type);
const row = document.createElement("div");
row.className = "policy-row";
row.innerHTML = `
${escapeHtml(info.label)}
${policy.allow_install ? "Install allowed" : "Install blocked"}
${policy.allow_download ? "Download allowed" : "Download blocked"} - ${escapeHtml(info.description)}
`;
return row;
});
els.sourcePolicyList.replaceChildren(...rows);
}
function renderLogs() {
els.logOutput.textContent = state.logs.length ? state.logs.join("\n") : "No events yet.";
}
async function saveConfig() {
const config = {
...state.config,
default_policy: els.defaultPolicy.value,
download_dir: els.downloadDir.value.trim(),
install_after_download: els.installAfterDownload.checked,
delete_pkg_after_install: els.deleteAfterInstall.checked,
verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads),
home_shortcut: els.homeShortcut ? els.homeShortcut.checked : state.config.home_shortcut !== false,
};
try {
await postJson(API.config, config);
state.config = config;
showToast("Configuration saved.");
} catch (error) {
state.config = config;
showToast("Configuration updated locally. API is not reachable yet.");
}
renderSettings();
renderGames(); // install_after_download changes the button label
}
/* ---------------- actions (data layer) ---------------- */
async function doDownload(game) {
game.downloading = true;
game._localDownloading = true; // this client owns it; don't let a poll clear it
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
state.downloads.push({ title_id: game.title_id, name: game.name, version: game.compatible_version || "", progress: 0, bytes: 0, total_bytes: 0 });
state.logs.push(`[${timeNow()}] Download started: ${game.title_id} ${game.compatible_version}`);
renderGames();
renderLogs();
startDownloadPolling();
let r;
try {
r = await postJson(API.action(game.title_id, "download"), {});
} catch (error) {
game.downloading = false;
game._localDownloading = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
const why = reasonText(error);
state.logs.push(`[${timeNow()}] download ${game.title_id} blocked: ${why}`);
showToast(`${game.name}: ${why}`);
renderGames(); renderLogs(); stopDownloadPolling();
return false;
}
game.downloading = false;
game._localDownloading = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
// Cancel / soft failure returns HTTP 200 with ok:false (not thrown).
if (!r || r.ok === false) {
game.downloaded = false;
const what = r && r.cancelled ? "cancelled" : "failed";
state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`);
showToast(`${game.name}: download ${what}.`);
renderGames(); renderLogs(); stopDownloadPolling();
return false;
}
game.downloaded = true;
const sz = r && r.bytes ? formatBytes(r.bytes) : "?";
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${sz}, internal)`);
showToast(`${game.name}: downloaded ${sz}.`);
renderGames(); renderLogs(); stopDownloadPolling();
return true;
}
async function doInstall(game) {
game.installing = true;
game.downloaded = false; // the package is being consumed by the install
renderGames();
try {
await postJson(API.action(game.title_id, "install"), {});
} catch (error) {
game.installing = false;
const why = reasonText(error);
state.logs.push(`[${timeNow()}] install ${game.title_id} blocked: ${why}`);
showToast(`${game.name}: ${why}`);
renderGames(); renderLogs();
return false;
}
state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`);
showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`);
renderGames(); renderLogs();
return true;
}
// Cancel a running download (server aborts + deletes the partial) or delete a
// finished package. Same endpoint handles both.
async function cancelDownload(titleId) {
const game = state.titles.find((g) => g.title_id === titleId);
try {
await postJson(API.action(titleId, "cancel"), {});
showToast(`${game ? game.name : titleId}: download cancelled / deleted.`);
} catch (error) {
showToast(`${game ? game.name : titleId}: ${reasonText(error)}`);
}
if (game) { game.downloading = false; game._localDownloading = false; game.downloaded = false; }
state.downloads = state.downloads.filter((i) => i.title_id !== titleId);
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`);
renderGames(); renderLogs();
refreshDownloads();
}
async function runTitleAction(titleId, action) {
const game = state.titles.find((i) => i.title_id === titleId);
if (!game) return;
if (action === "download") await doDownload(game);
else if (action === "install") await doInstall(game);
else if (action === "cancel") await cancelDownload(titleId);
else if (action === "update") { if (await doDownload(game)) await doInstall(game); }
}
function updateGame(titleId, patch) {
const game = state.titles.find((i) => i.title_id === titleId);
if (!game) return;
Object.assign(game, patch);
renderGames();
const wantEnabled = game.enabled !== false;
postJson(API.action(titleId, wantEnabled ? "enable" : "disable"), {})
.then(() => { state.logs.push(`[${timeNow()}] Saved: ${titleId} ${wantEnabled ? "enabled" : "disabled"}`); renderLogs(); })
.catch(() => showToast(`${titleId}: could not save toggle (API not reachable).`));
}
/* ---------------- policy helpers ---------------- */
function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; }
function isInstallAllowed(game) {
return Boolean(
game.enabled !== false &&
game.compatible_version &&
game.patch_title_match !== false &&
sourcePolicy(game).allow_install
);
}
function sourcePolicy(game) { return sourcePolicyForType(sourceType(game)); }
function sourcePolicyForType(type) {
const fb = fallback.config.source_policy[type] || fallback.config.source_policy.unknown;
const cfg = (state.config.source_policy || {})[type] || {};
return { ...fb, ...cfg };
}
async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const err = new Error(data.reason || data.message || `HTTP ${response.status}`);
err.body = data;
err.status = response.status;
throw err;
}
return data;
}
const REASON_TEXT = {
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
install_not_allowed_for_source: "Install blocked for this source.",
source_unknown: "Source unknown — blocked.",
no_compatible_patch: "No compatible patch available.",
download_failed: "Download failed.",
download_in_progress: "Another download is already running.",
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
title_disabled: "This title is disabled.",
};
function reasonText(error) {
const r = error && error.body && error.body.reason;
return (r && REASON_TEXT[r]) || (error && error.message) || "failed";
}
/* ---------------- formatting ---------------- */
function initials(name) {
return (name || "PD").split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
}
// Auto-scaled byte sizes: B / KB / MB / GB / TB.
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n < 0) return "--";
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let v = n / 1024, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
}
// Free space comes from the backend in MB.
function formatMb(mb) {
const n = Number(mb);
if (!Number.isFinite(n)) return "--";
return formatBytes(n * 1024 * 1024);
}
function formatEta(seconds) {
const s = Math.round(Number(seconds) || 0);
if (!Number.isFinite(s) || s <= 0) return "--";
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function clone(value) { return JSON.parse(JSON.stringify(value)); }
function timeNow() { return new Date().toLocaleTimeString("en-US", { hour12: false }); }
let toastTimer;
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 2600);
}