mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 11:00:25 +02:00
Resume: write the sidecar after every completed piece instead of batching every 8. Each piece's bytes are already fdatasync'd and the sidecar write is a tiny atomic tmp+rename, so an unclean kill now re-downloads only the pieces still in flight, not a batch of up-to-8 already-finished ones. Drops the now -unused 'unpersisted' counter. UI: the "parallel download connections" control is now a stepper — two large 54px -/+ buttons around a tabular value, in a row beside its label, instead of a full-width number field for a 1-16 value. Big targets and a clear green focus ring suit controller navigation (the UI is driven by the PS5 pad via the home tile). Tapping -/+ updates and auto-saves that field alone (debounced), applying live on the server.
891 lines
34 KiB
JavaScript
891 lines
34 KiB
JavaScript
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,
|
|
max_connections: 4,
|
|
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"),
|
|
connValue: document.getElementById("connValue"),
|
|
connMinus: document.getElementById("connMinus"),
|
|
connPlus: document.getElementById("connPlus"),
|
|
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(); });
|
|
if (els.connMinus)
|
|
els.connMinus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) - 1));
|
|
if (els.connPlus)
|
|
els.connPlus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) + 1));
|
|
}
|
|
|
|
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.
|
|
// Carry client-only flags across a refresh; the pool's job list is the source
|
|
// of truth for download state and is reconciled right after.
|
|
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
|
|
titles.forEach((g) => {
|
|
const old = prev.get(g.title_id);
|
|
if (old && g.status !== "up_to_date") {
|
|
if (old.downloaded) g.downloaded = true;
|
|
if (old.installing) g.installing = true;
|
|
if (old._autoInstalled) g._autoInstalled = true;
|
|
if (old._localDownloading) g._localDownloading = true;
|
|
}
|
|
});
|
|
|
|
state = { ...state, status, config, titles, downloads };
|
|
reconcileFromJobs(downloads);
|
|
|
|
render();
|
|
if (downloads.some((j) => j.state === "active" || j.state === "queued") ||
|
|
state.titles.some((g) => g._localDownloading))
|
|
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`;
|
|
}
|
|
|
|
const CONN_MIN = 1, CONN_MAX = 16;
|
|
function clampConn(n) {
|
|
n = parseInt(n, 10);
|
|
if (!Number.isFinite(n)) n = 4;
|
|
return Math.max(CONN_MIN, Math.min(CONN_MAX, n));
|
|
}
|
|
|
|
function renderConnStepper() {
|
|
const n = clampConn(state.config.max_connections);
|
|
if (els.connValue) els.connValue.textContent = String(n);
|
|
if (els.connMinus) els.connMinus.disabled = n <= CONN_MIN;
|
|
if (els.connPlus) els.connPlus.disabled = n >= CONN_MAX;
|
|
}
|
|
|
|
let connSaveTimer = null;
|
|
// Stepper +/-: update + re-render immediately, then persist just this field
|
|
// (debounced) so rapid taps collapse into one POST and other unsaved form
|
|
// fields stay untouched. The server applies the new count live (no restart).
|
|
function setConnections(n) {
|
|
const v = clampConn(n);
|
|
if (v === clampConn(state.config.max_connections)) { renderConnStepper(); return; }
|
|
state.config.max_connections = v;
|
|
renderConnStepper();
|
|
clearTimeout(connSaveTimer);
|
|
connSaveTimer = setTimeout(async () => {
|
|
try {
|
|
await postJson(API.config, { max_connections: v });
|
|
showToast(`Parallel connections: ${v}`);
|
|
} catch (e) {
|
|
showToast("Could not save connections — API not reachable.");
|
|
}
|
|
}, 450);
|
|
}
|
|
|
|
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;
|
|
renderConnStepper();
|
|
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 = `
|
|
<h3>${escapeHtml(game.name)}</h3>
|
|
<div class="subline">
|
|
<span>${escapeHtml(game.title_id)}</span>
|
|
<span>${escapeHtml(game.content_id || "")}</span>
|
|
</div>
|
|
<div class="pills">
|
|
${game.downloading ? `<span class="pill live">Downloading</span>` : ""}
|
|
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
|
|
${statusPill(game)}
|
|
${sourcePill(game)}
|
|
</div>
|
|
<div class="versions">
|
|
<span>Installed <b>${escapeHtml(game.installed_version || "--")}</b></span>
|
|
<span>Compatible <b>${escapeHtml(game.compatible_version || "None")}</b></span>
|
|
<span>Latest <b>${escapeHtml(game.latest_version || "--")}</b>${game.latest_required_fw ? ` · FW ${escapeHtml(game.latest_required_fw)}` : ""}</span>
|
|
</div>
|
|
`;
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "card-actions";
|
|
[primaryButton(game), stopButton(game)].forEach((act) => {
|
|
if (!act) return;
|
|
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);
|
|
});
|
|
|
|
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 = `
|
|
<div class="progress"><i style="width:${pctOf(d)}%"></i></div>
|
|
<div class="progress-meta">${progressMetaHtml(d)}</div>
|
|
`;
|
|
card.appendChild(prog);
|
|
} else if (game.resumable && game.partial_bytes > 0) {
|
|
// ---- paused partial (survived a reboot) ----
|
|
const note = document.createElement("div");
|
|
note.className = "card-progress";
|
|
note.innerHTML = `
|
|
<div class="progress-meta">
|
|
<span>Paused — <b>${formatBytes(game.partial_bytes)}</b> downloaded</span>
|
|
<span>Resume to continue</span>
|
|
</div>
|
|
`;
|
|
card.appendChild(note);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Primary play/pause button (green to go, amber while downloading). Fixed width
|
|
// (CSS) so the label never reflows. Returns null when there is nothing to do.
|
|
function primaryButton(game) {
|
|
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
|
|
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
|
|
if (game.patch_title_match === false) return null;
|
|
if (!isInstallAllowed(game)) return null;
|
|
if (game.resumable)
|
|
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
|
|
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." };
|
|
}
|
|
|
|
// Red stop button: present whenever there is a download to stop or discard.
|
|
// Cancel stops AND deletes (unlike Pause, which keeps the partial).
|
|
function stopButton(game) {
|
|
if (game.installing) return null;
|
|
if (game.downloading || game.resumable || game.downloaded)
|
|
return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop and delete the download." };
|
|
return null;
|
|
}
|
|
|
|
function statusPill(game) {
|
|
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
|
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
|
|
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
|
|
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
|
|
if (game.status === "incompatible_fw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
|
|
if (game.status === "up_to_date") return `<span class="pill">Up to date</span>`;
|
|
return `<span class="pill">No patch info</span>`;
|
|
}
|
|
|
|
function sourcePill(game) {
|
|
const info = sourceInfo(game);
|
|
return `<span class="pill ${info.className}">${escapeHtml(info.label)}</span>`;
|
|
}
|
|
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;
|
|
if (d.state === "queued") return `<span>Queued — waiting for a free slot</span>`;
|
|
const parts = [];
|
|
parts.push(`<span><b>${formatBytes(done)}</b>${total > 0 ? ` / ${formatBytes(total)}` : ""}</span>`);
|
|
if (speed > 0) parts.push(`<span><b>${formatBytes(speed)}/s</b></span>`);
|
|
if (speed > 0 && total > done) parts.push(`<span>ETA <b>${formatEta((total - done) / speed)}</b></span>`);
|
|
else if (!total) parts.push(`<span>Fetching manifest…</span>`);
|
|
parts.push(`<span><b>${pctOf(d)}%</b></span>`);
|
|
return parts.join("");
|
|
}
|
|
|
|
function startDownloadPolling() {
|
|
emptyPolls = 0;
|
|
if (downloadPollTimer) return;
|
|
downloadPollTimer = setInterval(refreshDownloads, 2000);
|
|
refreshDownloads();
|
|
}
|
|
|
|
function stopDownloadPolling() {
|
|
if (!downloadPollTimer) return;
|
|
clearInterval(downloadPollTimer);
|
|
downloadPollTimer = null;
|
|
}
|
|
|
|
// Map the pool's job list onto per-title flags, and auto-install once a job
|
|
// finishes if "install after download" is on.
|
|
function reconcileFromJobs(jobs) {
|
|
const byId = new Map((jobs || []).map((j) => [j.title_id, j]));
|
|
state.titles.forEach((g) => {
|
|
const j = byId.get(g.title_id);
|
|
if (!j) {
|
|
// The pool never produced a job for our local intent: time it out so the
|
|
// card can't wedge in "Downloading" forever (server restart between POST
|
|
// and poll, or an unexpected response shape).
|
|
if (g._localDownloading && g._localSince &&
|
|
Date.now() - g._localSince > 12000) {
|
|
g._localDownloading = false;
|
|
}
|
|
if (g.downloading && !g._localDownloading) g.downloading = false;
|
|
return;
|
|
}
|
|
g._localDownloading = false; // the pool now tracks it
|
|
if (j.state === "active" || j.state === "queued") {
|
|
g.downloading = true;
|
|
g.resumable = false;
|
|
g._wasActive = true;
|
|
} else if (j.state === "paused") {
|
|
g.downloading = false;
|
|
g.resumable = true;
|
|
g.partial_bytes = Number(j.bytes) || g.partial_bytes || 0;
|
|
g._wasActive = false;
|
|
} else if (j.state === "done") {
|
|
g.downloading = false;
|
|
g.resumable = false;
|
|
g.downloaded = true;
|
|
g._wasActive = false;
|
|
if (state.config.install_after_download && !g.installing && !g._autoInstalled) {
|
|
g._autoInstalled = true;
|
|
doInstall(g);
|
|
}
|
|
} else if (j.state === "error") {
|
|
// The server keeps the partial + sidecar (resumable) on a post-retry
|
|
// network failure. Reflect that so primaryButton shows "Resume" and the
|
|
// red Cancel stays available to delete the kept partial.
|
|
const bytes = Number(j.bytes) || 0;
|
|
if (g._wasActive) {
|
|
showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`);
|
|
}
|
|
g._wasActive = false;
|
|
g.downloading = false;
|
|
g.resumable = bytes > 0;
|
|
g.partial_bytes = bytes || g.partial_bytes || 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refreshDownloads() {
|
|
let jobs;
|
|
try {
|
|
const response = await fetch(API.downloads, { cache: "no-store" });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
jobs = await response.json();
|
|
} catch (error) {
|
|
return; // keep last state on a transient failure
|
|
}
|
|
|
|
const now = Date.now();
|
|
const ids = new Set();
|
|
jobs.forEach((j) => {
|
|
ids.add(j.title_id);
|
|
const done = Number(j.bytes) || 0;
|
|
const prev = dlMeta[j.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
|
|
}
|
|
}
|
|
const meta = prev || (dlMeta[j.title_id] = { speed: 0 });
|
|
meta.bytes = done;
|
|
meta.t = now;
|
|
j._speed = meta.speed || 0;
|
|
});
|
|
Object.keys(dlMeta).forEach((id) => { if (!ids.has(id)) delete dlMeta[id]; });
|
|
|
|
state.downloads = jobs;
|
|
const before = downloadingIds();
|
|
reconcileFromJobs(jobs);
|
|
if (downloadingIds() !== before) renderGames();
|
|
else applyDownloadProgress();
|
|
|
|
const busy = jobs.some((j) => j.state === "active" || j.state === "queued") ||
|
|
state.titles.some((g) => g._localDownloading);
|
|
if (!busy) { 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) => {
|
|
// Only titles currently downloading render a progress tile; paused/done/error
|
|
// jobs linger in the pool list but have no .progress bar — skip them so a
|
|
// missing tile for a non-downloading title doesn't force a full rebuild.
|
|
const g = state.titles.find((t) => t.title_id === d.title_id);
|
|
if (!g || !g.downloading) return;
|
|
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 = `
|
|
<span class="pill ${info.className}">${escapeHtml(info.label)}</span>
|
|
<strong>${policy.allow_install ? "Install allowed" : "Install blocked"}</strong>
|
|
<em>${policy.allow_download ? "Download allowed" : "Download blocked"} - ${escapeHtml(info.description)}</em>
|
|
`;
|
|
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,
|
|
max_connections: clampConn(state.config.max_connections),
|
|
};
|
|
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) ---------------- */
|
|
|
|
// Enqueue a download. The pool returns immediately (202); progress, completion
|
|
// and (if configured) auto-install are driven by reconcileFromJobs() on poll.
|
|
async function doDownload(game) {
|
|
game.downloading = true;
|
|
game._localDownloading = true; // until the pool reports a job for this title
|
|
game._localSince = Date.now(); // bounded in reconcileFromJobs if no job appears
|
|
game._autoInstalled = false;
|
|
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 || "",
|
|
state: "queued", progress: 0, bytes: 0, total_bytes: 0 });
|
|
renderGames();
|
|
startDownloadPolling();
|
|
|
|
try {
|
|
const r = await postJson(API.action(game.title_id, "download"), {});
|
|
if (r && r.downloaded && r.already) {
|
|
game.downloading = false;
|
|
game._localDownloading = false;
|
|
game.downloaded = true;
|
|
renderGames();
|
|
} else {
|
|
state.logs.push(`[${timeNow()}] Download queued: ${game.title_id} ${game.compatible_version || ""}`);
|
|
renderLogs();
|
|
}
|
|
} 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();
|
|
}
|
|
}
|
|
|
|
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;
|
|
game.resumable = false;
|
|
game.partial_bytes = 0;
|
|
}
|
|
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;
|
|
// "update" and "download" both just enqueue; for "update" (install-after-
|
|
// download on) the reconciler auto-installs once the pool reports it done.
|
|
if (action === "download" || action === "update") await doDownload(game);
|
|
else if (action === "install") await doInstall(game);
|
|
else if (action === "pause") await doPause(game);
|
|
else if (action === "cancel") await cancelDownload(titleId);
|
|
}
|
|
|
|
// Pause only sends the signal; the in-flight doDownload() request returns its
|
|
// "paused" result and updates the card (resumable + partial bytes).
|
|
async function doPause(game) {
|
|
try {
|
|
await postJson(API.action(game.title_id, "pause"), {});
|
|
showToast(`${game.name}: pausing…`);
|
|
} catch (error) {
|
|
showToast(`${game.name}: ${reasonText(error)}`);
|
|
}
|
|
}
|
|
|
|
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.",
|
|
download_paused: "Download paused.",
|
|
not_downloading: "Nothing is downloading for this title.",
|
|
};
|
|
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, """)
|
|
.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);
|
|
}
|