const API = { status: "/api/status", titles: "/api/titles", config: "/api/config", downloads: "/api/downloads", installStatus: "/api/installstatus", rescan: "/api/rescan", storage: "/api/storage", storageDelete: (titleId) => `/api/storage/${encodeURIComponent(titleId)}/delete`, action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`, }; // title_id -> loaded , so re-rendered cards show icons instantly. const iconCache = new Map(); // Labels come from i18n.js: source. and source..desc. const SOURCE_TYPES = { official: { className: "ok", installAllowed: true }, external: { className: "external", installAllowed: true }, shadowmount: { className: "shadow", installAllowed: false }, unknown: { className: "blocked", installAllowed: false }, fake: { className: "blocked", installAllowed: false }, }; function sourceLabel(type) { return t(`source.${type}`); } function sourceDescription(type) { return t(`source.${type}.desc`); } // Text for a code from a server/state map: if translated, // else the fallback. function tCode(prefix, code, fallbackText) { return code && tHas(prefix + code) ? t(prefix + code) : fallbackText; } const fallback = { status: { firmware: "11.60", firmware_build: "0x11600000", dns_guard: "Active", resolver: "Internal allowlist", free_space_mb: 771916, download_dir: "/data/patchdl (internal)", lan_ip: "192.168.178.90", http_port: 12880, }, 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: false, allow_install: false }, unknown: { allow_check: true, allow_download: false, allow_install: false }, fake: { 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", "gs-sec.ww.np.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", patch_storage_match: true, 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", patch_storage_match: true, 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", patch_storage_match: true, mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "blocked", }, { title_id: "PPSA03396", name: "The Last of Us Part I", content_id: "UP9000-PPSA03396_00-THELASTOFUSPART1", installed_version: "01.000.000", compatible_version: null, latest_version: "02.002.000", latest_required_fw: "10.20", prospero_version: "01.003.000", prospero_required_fw: "7.01", prospero_size: "71.7 GB", prospero_done: true, source_type: "official", source_path: "/system_ex/app/PPSA03396", patch_storage_match: true, mount_from: "/dev/ssd0.system_ex", enabled: true, status: "incompatible_fw", }, ], downloads: [], logs: [], }; let state = { status: fallback.status, config: fallback.config, titles: fallback.titles, downloads: fallback.downloads, logs: fallback.logs, view: "games", filter: "updates", query: "", usingFallback: false, }; let downloadPollTimer = null; let installPollTimer = null; let emptyPolls = 0; const dlMeta = {}; // per-title speed tracking: { bytes, t, speed } const els = {}; document.addEventListener("DOMContentLoaded", () => { applyI18n(); bindElements(); fillLanguageSelect(); bindEvents(); loadInitialData().then(() => { openGuideFromHash(); openDiagFromHash(); }); window.addEventListener("hashchange", openGuideFromHash); setInterval(watchTitles, 5000); watchCoverErrors(); document.querySelectorAll("[data-donate]").forEach((b) => b.addEventListener("click", openDonate)); // Cards are re-rendered often; listen once on the document. document.addEventListener("click", (e) => { const b = e.target.closest("[data-diag]"); if (b) openDiag(b.dataset.diag); const v = e.target.closest("[data-versions]"); if (v) openVersions(v.dataset.versions); }); window.addEventListener("hashchange", openDiagFromHash); }); /* ---------------- dialogs ---------------- */ function icon(name) { return ``; } function closeButtonHtml() { return ``; } // Modal sheet: Escape, the close button and a click on the backdrop close // it, and focus goes back to where it was. function openSheet(html, labelledBy, extraClass = "") { const back = document.activeElement; const wrap = document.createElement("div"); wrap.className = "guide-backdrop"; wrap.innerHTML = ``; const close = () => { document.removeEventListener("keydown", onKey); wrap.remove(); if (back && document.contains(back)) back.focus(); }; const onKey = (e) => { if (e.key === "Escape") close(); }; wrap.querySelector(".guide-close").addEventListener("click", close); wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); }); document.addEventListener("keydown", onKey); document.body.appendChild(wrap); wrap.querySelector(".guide-close").focus(); return { wrap, close }; } // The game's icon for a dialog header; initials until it has loaded. // A broken icon falls back to the initials (see watchCoverErrors). function coverHtml(game, cls) { const src = game && game.has_icon ? `/api/icon/${encodeURIComponent(game.title_id)}` : ""; const ini = escapeHtml(initials(game ? game.name : "")); return ``; } // Image errors don't bubble; catch them on the way down instead. function watchCoverErrors() { document.addEventListener("error", (e) => { const img = e.target; if (img.tagName !== "IMG" || !img.parentElement) return; const cover = img.parentElement; if (cover.dataset.initials !== undefined) cover.textContent = cover.dataset.initials; }, true); } /* ---------------- other versions ---------------- */ // Every version Prospero (PS5) or Orbis (PS4) lists for a game, grouped: // what can be picked here first, then what needs a newer firmware, the // installed one, and older ones folded away. Picking one opens the guide // for exactly that version. async function openVersions(titleId) { const game = state.titles.find((g) => g.title_id === titleId); if (!game) return; const { wrap, close } = openSheet(`
${coverHtml(game, "cover-sm")}

${t("versions.title")}

${escapeHtml(game.name)}

${escapeHtml(t("card.installed", { version: game.installed_version || "?" }))}

${closeButtonHtml()}

${t("versions.loading")}

`, "verTitle", "is-sheet"); const data = await fetchJson(`/api/versions?title=${encodeURIComponent(titleId)}`); if (!document.contains(wrap)) return; const body = wrap.querySelector(".sheet-body"); if (!data || !data.ok) { body.innerHTML = `
${icon("alert")}${t("versions.loadFailed")}
`; return; } const rows = (data.versions || []).slice().sort((a, b) => (a.version < b.version ? 1 : -1)); const group = (st) => rows.filter((r) => r.state === st); const ok = group("ok"), locked = group("needs_fw"), installed = group("installed"), older = group("older"); // A locked row names the firmware in its badge already. const meta = (r) => [r.size, !data.ps4 && r.fw && r.state !== "needs_fw" ? `FW ${r.fw}` : ""] .filter(Boolean).map(escapeHtml).join(" · "); const row = (r, extra) => `
  • ${escapeHtml(r.version)}${meta(r) ? `${meta(r)}` : ""}
    ${extra}
  • `; const section = (title, list, render, cls = "") => list.length ? `

    ${title} ${list.length}

      ${list.map(render).join("")}
    ` : ""; body.innerHTML = ` ${ok.length ? `

    ${t("versions.pickHint")}

    ` : `
    ${icon("check")}${rows.length ? t("versions.noneNewer") : t("versions.none")}
    `} ${section(t("versions.available"), ok, (r) => row(r, ``))} ${section(t("versions.needsFwGroup"), locked, (r) => row(r, `${icon("lock")}${t("card.needsFw", { fw: escapeHtml(r.fw || "?") })}`))} ${installed.length ? `
      ${installed.map((r) => row(r, `${icon("check")}${t("versions.installedGroup")}`)).join("")}
    ` : ""} ${older.length ? `
    ${icon("chevron")}${t("versions.olderGroup", { count: older.length })}
      ${older.map((r) => row(r, "")).join("")}
    ` : ""}

    ${t("versions.source", { site: `${escapeHtml(data.site)}${icon("external")}` })}

    `; body.querySelectorAll("[data-pick]").forEach((btn) => btn.addEventListener("click", () => { close(); openGuide(game, false, pickFor(game, btn.dataset.pick, btn.dataset.size)); })); const first = body.querySelector("[data-pick]"); if (first) first.focus(); } /* ---------------- download diagnosis ---------------- */ function diagLink(titleId) { const h = location.hostname; let base = location.origin; if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") { if (!state.status.lan_ip) return null; base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`; } return `${base}/#diag/${encodeURIComponent(titleId)}`; } function openDiagFromHash() { const m = location.hash.match(/^#(diag|versions)\/([A-Za-z0-9_.-]+)$/); if (!m) return; history.replaceState(null, "", location.pathname + location.search); if (m[1] === "diag") openDiag(m[2]); else openVersions(m[2]); } async function fetchJson(url) { try { const r = await fetch(url, { cache: "no-store" }); return r.ok ? await r.json() : null; } catch (_) { return null; } } function diagRow(ok, label, detail) { return `
  • ${ok ? "✓" : "✗"} ${escapeHtml(label)}${detail ? `
    ${escapeHtml(detail)}` : ""}
  • `; } // Runs the network check for one game and shows it as a checklist plus a // report to paste into an issue. On the PS5 (no clipboard) a QR code opens // the same dialog on a phone. async function openDiag(titleId) { const game = state.titles.find((g) => g.title_id === titleId); const name = game ? game.name : titleId; const { wrap } = openSheet(`
    ${coverHtml(game, "cover-sm")}

    ${t("diag.eyebrow")}

    ${escapeHtml(name)}

    ${escapeHtml(titleId)}

    ${closeButtonHtml()}

    ${t("diag.checking")}

    `, "diagTitle"); const [check, status, downloads, pkg, inst] = await Promise.all([ fetchJson(`/api/netcheck?title=${encodeURIComponent(titleId)}`), fetchJson(API.status), fetchJson(API.downloads), fetchJson("/api/pkgdiag"), fetchJson(API.installStatus), ]); if (!document.contains(wrap)) return; const job = (downloads || []).find((d) => d.title_id === titleId) || null; const ul = check && check.update_list, mf = check && check.manifest; const mfDetail = !mf ? "" : mf.ok && mf.step === "direct" ? t("diag.singlePackage") : mf.ok ? t("diag.pieces", { count: mf.pieces }) : mf.url ? failStepText({ fail_step: mf.step || "manifest", last_http_code: mf.http }) : t("diag.noPackage"); const rows = [ diagRow(Boolean(ul && ul.ok), t("diag.updateList"), !ul ? t("diag.noAnswer") : ul.ok ? t("diag.newest", { version: ul.latest || "?" }) : !ul.url ? t("diag.noUpdateUrl") : failStepText({ fail_step: ul.step || "network", last_http_code: ul.http })), diagRow(Boolean(mf && mf.ok), t("diag.manifest"), mfDetail), ]; if (job) rows.push(diagRow(job.state !== "error", t("diag.lastDownload"), job.state === "error" ? (Number(job.rc) === -3 ? noSpaceText(job.need_bytes, job.free_bytes, job.space_install) : job.fail_step ? failStepText(job) : t("diag.failedPieces")) : tCode("diag.jobState.", job.state, job.state))); const report = [ "PatchDL diagnostic report", `PatchDL ${status ? status.version : "?"} · FW ${status ? status.firmware : "?"}`, `Game: ${name} (${titleId})${game ? ` · installed ${game.installed_version || "?"} · ${game.source_type}${game.sideloaded ? ", sideloaded" : ""}` : ""}`, `Download: ${job ? JSON.stringify(job) : "none"}`, `Network check: ${check ? JSON.stringify(check) : "unavailable"}`, `Install: ${inst ? JSON.stringify(inst.streams || inst) : "unavailable"}`, `Console requests: ${pkg && pkg.log ? pkg.log.slice(-8).join(" | ") : "none"}`, ...(state.logs.length ? ["Log:", ...state.logs.slice(-15).map((e) => logLine(e, tEn))] : []), ].join("\n"); const onConsole = isConsoleBrowser(); const link = onConsole ? diagLink(titleId) : null; const matrix = link ? qrEncode(link) : null; wrap.querySelector(".diag-body").innerHTML = `
      ${rows.join("")}
    ${onConsole ? (matrix ? `
    ${qrSvg(matrix)}
    ${t("diag.reportPhone")}
    ` : "") : `

    ${t("diag.reportHint")}

    `} ${onConsole ? "" : ``}`; const copy = wrap.querySelector(".diag-copy"); if (copy) copy.addEventListener("click", async () => { const ta = wrap.querySelector(".diag-report"); try { await navigator.clipboard.writeText(report); } catch (_) { ta.select(); document.execCommand("copy"); } showToast(t("diag.copied")); }); } const DONATE_URL = "https://paypal.me/oliverkoester"; // Donation dialog. On the TV a link is no use, so it shows a QR code for // the phone (hidden on phones, where the button opens PayPal directly). function openDonate() { const matrix = qrEncode(DONATE_URL); openSheet(`

    ${t("donate.eyebrow")}

    ${t("donate.title")}

    ${closeButtonHtml()}

    ${t("donate.why")}

    ${matrix ? `
    ${qrSvg(matrix)}
    ${t("donate.scan")} ${escapeHtml(DONATE_URL)}
    ` : ""}

    ${escapeHtml(t("donate.small"))}

    `, "donateTitle"); } // The server rescans on its own when games are added or removed and bumps // titles_gen whenever the list or an update check changes; reload quietly then. let watchBusy = false; async function watchTitles() { if (watchBusy) return; // a slow reload is still running watchBusy = true; try { const response = await fetch(API.status, { cache: "no-store" }); if (!response.ok) return; const status = await response.json(); const changed = state.status && status.titles_gen !== state.status.titles_gen; if (changed) await loadInitialData({ quiet: true }); else { state.status = status; renderStatus(); } } catch (_) { /* offline for a moment; the next tick retries */ } finally { watchBusy = false; } } // Refresh button: ask for a rescan right away, then reload once it had time. async function rescanNow() { try { await postJson(API.rescan, {}); } catch (_) { /* older server: plain reload */ } await new Promise((resolve) => setTimeout(resolve, 1500)); await 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"), storageList: document.getElementById("storageList"), storageTotal: document.getElementById("storageTotal"), searchInput: document.getElementById("searchInput"), defaultPolicy: document.getElementById("defaultPolicy"), downloadDrive: document.getElementById("downloadDrive"), 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"), updateAllBtn: document.getElementById("updateAllBtn"), brandVersion: document.getElementById("brandVersion"), globalDl: document.getElementById("globalDl"), globalDlState: document.getElementById("globalDlState"), globalDlName: document.getElementById("globalDlName"), globalDlPosition: document.getElementById("globalDlPosition"), globalDlPct: document.getElementById("globalDlPct"), globalDlSpeed: document.getElementById("globalDlSpeed"), globalDlEta: document.getElementById("globalDlEta"), globalDlBar: document.getElementById("globalDlBar"), saveBtn: document.getElementById("saveBtn"), language: document.getElementById("language"), 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", rescanNow); if (els.updateAllBtn) els.updateAllBtn.addEventListener("click", updateAll); 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)); } // Language selector in Settings: applies at once (reloads the page), no Save. function fillLanguageSelect() { const sel = document.getElementById("language"); if (!sel) return; const current = languageChoice(); sel.replaceChildren(...LANGUAGES.map(([code, name]) => { const opt = document.createElement("option"); opt.value = code; opt.textContent = code === "auto" ? t("settings.languageAuto") : name; if (code !== "auto") opt.lang = code; opt.selected = code === current; return opt; })); sel.addEventListener("change", () => setLanguage(sel.value)); } 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)); if (view === "settings") { loadStorage(); loadDrives(); } } async function loadInitialData({ quiet = false } = {}) { 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); // Neither state has a patch left to fetch, so drop stale job flags. // An install ends when the install poll says so, not when the list // already reports the new version (the console writes it mid-install). if (old && old.installing) { g.installing = true; g.installStatus = old.installStatus; g.installProgress = old.installProgress; g.installDone = old.installDone; g.installTotal = old.installTotal; } if (old && g.status !== "up_to_date" && (g.status !== "incompatible_fw" || g.manual_version)) { if (old._autoInstalled) g._autoInstalled = true; if (old._localDownloading) g._localDownloading = true; if (old._wasActive) g._wasActive = true; } }); // A background reload must not swap the real list for demo data because // one request failed for a moment. if (quiet && state.usingFallback) { state.usingFallback = false; return; } 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(); if (state.titles.some((g) => g.installing)) startInstallPolling(); // Pick up installs that are running while this page was (re)loaded. if (!quiet) refreshInstallStatus(); if (!quiet) showToast(state.usingFallback ? t("toast.demoData") : t("toast.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 || t("metric.firmwareBuild"); 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; const kept = Number(state.status.downloads_bytes) || 0; if (kept > 0) { // Packages on disk take space too; the link opens the list to manage them. els.downloadDirValue.innerHTML = ``; els.downloadDirValue.querySelector("button").addEventListener("click", () => setView("settings")); } else { els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || t("metric.downloadTarget"); } if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`; if (els.railSpace) els.railSpace.textContent = t("rail.free", { space }); if (els.brandVersion) els.brandVersion.textContent = state.status.version ? `v${state.status.version}` : "v--"; } 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(t("toast.connections", { count: v })); } catch (e) { showToast(t("toast.connectionsFailed")); } }, 450); } function renderSettings() { els.defaultPolicy.value = state.config.default_policy || "deny"; renderDrives(); 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() { renderGlobalStatus(); const counts = { updates: 0, uptodate: 0, blocked: 0, all: state.titles.length }; state.titles.forEach((g) => { counts[gameCategory(g)]++; }); document.querySelectorAll(".segmented button").forEach((b) => { const c = b.querySelector(".chip-count"); if (c) c.textContent = String(counts[b.dataset.filter] ?? ""); }); const visible = state.titles.filter(matchesFilter).filter(matchesQuery) .sort((a, b) => (a.name || a.title_id).localeCompare(b.name || b.title_id, undefined, { sensitivity: "base", numeric: true })); els.gameGrid.replaceChildren(); if (!visible.length) { const empty = document.createElement("div"); empty.className = "empty-state"; empty.textContent = t("games.empty"); els.gameGrid.appendChild(empty); return; } visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game))); } // One bucket per game for the filter chips, answering a single question: is // there something to get for this console? Where the patch comes from (Sony, // Prospero) and whether it installs or only downloads is shown on the card. function isPs4(game) { return (game.title_id || "").startsWith("CUSA"); } function gameCategory(game) { const policy = sourcePolicy(game); // Not supported = PatchDL can't install it (fake packages, mounted images). if (game.patch_title_match === false || !policy.allow_install || game.status === "blocked") return "blocked"; if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState) return "updates"; if (game.status === "checking" || game.status === "available" || hasOlderPatch(game)) return "updates"; return "uptodate"; } // Sony's newest patch needs newer firmware, but Prospero knows an older one // that runs here and is newer than what is installed. function hasOlderPatch(game) { return game.status === "incompatible_fw" && Boolean(game.prospero_version) && game.prospero_version > (game.installed_version || ""); } 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); if (game.has_icon) { // Cards re-render on every poll. Reuse an icon that already loaded so // it doesn't flash back to the initials; a failed icon keeps them. const loaded = iconCache.get(game.title_id); if (loaded) { cover.replaceChildren(loaded.cloneNode()); } else { const img = new Image(); img.alt = ""; img.decoding = "async"; img.onload = () => { iconCache.set(game.title_id, img); cover.replaceChildren(img.cloneNode()); }; img.src = `/api/icon/${encodeURIComponent(game.title_id)}`; } } 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)} ${isPs4(game) ? `PS4` : ""} ${game.downloading && game._jobState === "queued" ? `${t("pill.queued")}` : ""} ${game.downloading && game._jobState !== "queued" ? `${t("pill.downloading")}` : ""} ${game.resumable && !game.downloading ? `${t("pill.paused")}` : ""} ${statusPill(game)} ${sourcePill(game)} ${game.sideloaded ? `${t("pill.sideloaded")}` : ""}
    ${versionTrackHtml(game)} ${cardNotesHtml(game)} `; 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); }); if (canPickVersion(game, "99") && !game.manual_version && game.status !== "blocked") { const btn = document.createElement("button"); btn.type = "button"; btn.className = "row-button is-ghost"; btn.dataset.versions = game.title_id; btn.innerHTML = `${icon("layers")}${t("versions.title")}`; 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 = `
    ${progressMetaHtml(d)}
    `; card.appendChild(prog); } else if (game.installing) { const pct = Math.max(0, Math.min(100, Number(game.installProgress) || 0)); const indet = game.installStatus === "finishing" || game.installStatus === "waiting"; const note = document.createElement("div"); note.className = "card-progress"; note.innerHTML = `
    ${installProgressHtml(game)}
    `; card.appendChild(note); } else if (game.resumable && game.partial_bytes > 0) { // ---- paused partial (survived a reboot) ---- const note = document.createElement("div"); note.className = "card-progress"; note.innerHTML = `
    ${t("card.pausedDownloaded", { size: formatBytes(game.partial_bytes) })} ${t("card.resumeToContinue")}
    `; 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", t("card.enableAria", { name: game.name })); const track = document.createElement("span"); track.className = "track"; const lbl = document.createElement("span"); lbl.className = "label"; lbl.textContent = cb.checked ? t("card.updatesOn") : t("card.updatesOff"); cb.addEventListener("change", () => { lbl.textContent = cb.checked ? t("card.updatesOn") : t("card.updatesOff"); 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: t("btn.installing"), variant: "ghost", disabled: true }; if (game.downloading) return { label: t("btn.pause"), action: "pause", variant: "pause", hint: t("btn.pauseHint") }; if (game.patch_title_match === false) return null; if (canGetOlderPatch(game)) return { label: t("btn.oldFw"), action: "guide", variant: "update", hint: t("btn.oldFwHint", { version: game.prospero_version }) }; if (game.resumable && isDownloadAllowed(game)) return { label: t("btn.resume"), action: "download", variant: "update", hint: t("btn.resumeHint") }; const hasPatch = game.status === "available" || Boolean(game.manual_version); if (game.downloaded && hasPatch && isInstallAllowed(game)) return { label: t("btn.install"), action: "install", variant: "update", hint: t("btn.installHint") }; if (game.downloaded && hasPatch) return null; if (!hasPatch) return null; if (!isDownloadAllowed(game)) return null; return state.config.install_after_download && isInstallAllowed(game) ? { label: t("btn.update"), action: "update", variant: "update", hint: t("btn.updateHint") } : { label: t("btn.download"), action: "download", variant: "update", hint: t("btn.downloadHint") }; } // 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.downloaded && !game.downloading) return { label: t("btn.delete"), action: "cancel", variant: "cancel", hint: t("btn.deleteHint") }; if (game.downloading || game.resumable) return { label: t("btn.cancel"), action: "cancel", variant: "cancel", hint: t("btn.cancelHint") }; return null; } // Installed -> what PatchDL can get for this console -> newest overall with // the firmware it needs (left out when it's the same as the offered update). function versionTrackHtml(game) { const step = (label, version, note, cls = "") => `
    ${label}${escapeHtml(version)}${note ? `${note}` : ""}
    `; const parts = [step(t("card.lblInstalled"), game.installed_version || "--", "")]; let offered = ""; if (game.manual_version) { offered = game.manual_version; parts.push(step(t("card.lblUpdate"), offered, "", "is-target")); } else if (game.status === "available" && game.compatible_version) { offered = game.compatible_version; parts.push(step(t("card.lblUpdate"), offered, "", "is-target")); } else if (hasOlderPatch(game)) { offered = game.prospero_version; parts.push(step(t("card.lblUpdate"), offered, game.prospero_required_fw ? t("card.needsFw", { fw: escapeHtml(game.prospero_required_fw) }) : "", "is-target")); } if (game.latest_version && game.latest_version !== offered && game.latest_version !== game.installed_version) parts.push(step(t("card.lblNewest"), game.latest_version, game.latest_required_fw ? t("card.needsFw", { fw: escapeHtml(game.latest_required_fw) }) : "", "is-dim")); const arrow = `${icon("arrow")}`; // The arrow only leads to something PatchDL can get. return `
    ${offered ? parts[0] + arrow + parts.slice(1).join("") : parts.join("")}
    `; } // Problems and hints under the versions, each with what to do about it. function cardNotesHtml(game) { const notes = []; const job = state.downloads.find((d) => d.title_id === game.title_id); if (job && job.state === "error" && Number(job.rc) === -3) notes.push(`
    ${icon("alert")}${escapeHtml(noSpaceText(job.need_bytes, job.free_bytes, job.space_install))}
    `); else if (job && job.state === "error") notes.push(`
    ${icon("alert")}${escapeHtml(t("card.downloadFailed"))}${job.fail_step ? " " + escapeHtml(failStepText(job)) : ""}
    `); if (!game.manual_version && !hasOlderPatch(game) && game.status === "incompatible_fw" && !game.prospero_done) notes.push(`
    ${t("card.lookingForPatch")}
    `); if (game.sideloaded) notes.push(`
    ${icon("alert")}${escapeHtml(t("card.sideloadedNote"))}
    `); return notes.join(""); } function statusPill(game) { if (game.installing) return `${t("pill.installing")}`; if (game.status === "checking") return `${t("pill.checking")}`; if (game.status === "blocked" || !sourcePolicy(game).allow_install) return `${t("pill.notSupported")}`; if (game.patch_title_match === false) return `${t("pill.titleMismatch")}`; if (game.manual_version || hasOlderPatch(game)) return `${t("pill.via", { site: isPs4(game) ? "Orbis" : "Prospero" })}`; if (game.status === "available") return `${t("pill.available")}`; if (game.status === "incompatible_fw") return `${t("pill.newestNeedsFw", { fw: escapeHtml(game.latest_required_fw || "?") })}`; if (game.status === "up_to_date") return `${t("pill.upToDate")}`; return `${t("pill.noInfo")}`; } // Official installs are the normal case and need no badge; every other // source gets one. function sourcePill(game) { const info = sourceInfo(game); const type = SOURCE_TYPES[sourceType(game)] ? sourceType(game) : "unknown"; if (type === "official") return ""; return `${escapeHtml(sourceLabel(type))}`; } 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 `${t("progress.queued")}`; const parts = []; if (d.verifying) parts.push(`${t("progress.verifying")}`); if (d.waiting && !(Number(d._speed) > 0)) parts.push(`${t("progress.retrying")}`); parts.push(`${formatBytes(done)}${total > 0 ? ` / ${formatBytes(total)}` : ""}`); if (speed > 0) parts.push(`${formatBytes(speed)}/s`); if (speed > 0 && total > done) parts.push(`${t("progress.eta", { eta: formatEta((total - done) / speed) })}`); else if (!total) parts.push(`${t("progress.fetchingManifest")}`); parts.push(`${pctOf(d)}%`); return parts.join(""); } // One sweep of the indeterminate bar. Cards are rebuilt on every list // update; a negative delay taken from the clock lets a rebuilt bar continue // where the old one was instead of starting over. const INDET_MS = 1600; // Install phases with their own text (install.phase. in i18n.js). const INSTALL_PHASES = ["waiting", "reading", "finishing"]; function installProgressHtml(game) { const status = game.installStatus || "waiting"; const done = Number(game.installDone) || 0; const total = Number(game.installTotal) || 0; const phase = INSTALL_PHASES.includes(status) ? t(`install.phase.${status}`) : ""; if (phase) { const parts = [`${phase}`]; if (status === "reading" && total > 0) { parts.push(`${formatBytes(done)} / ${formatBytes(total)}`); parts.push(`${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%`); } return parts.join(""); } const parts = [`${t("install.status", { status: escapeHtml(status) })}`]; if (total > 0) parts.push(`${formatBytes(done)} / ${formatBytes(total)}`); parts.push(`${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%`); 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; g._jobState = null; return; } // A download started from this page counts as watched even if it // finished before a poll saw it running (a verified package on disk). if (g._localDownloading) g._wasActive = true; g._localDownloading = false; // the pool now tracks it g._jobState = j.state; if (j.state === "active" || j.state === "queued") { g._spaceWarned = false; 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") { // Auto-install once, on the download finishing while this page watched // it, and only while the update is still pending. A finished job stays // in the pool; without these checks every poll would retry the install // (refused as already installed) and redraw the list each time. const justFinished = g._wasActive; const pending = g.status === "available" || Boolean(g.manual_version); g.downloading = false; g.resumable = false; g.downloaded = true; g._wasActive = false; if (justFinished && pending && state.config.install_after_download && isInstallAllowed(g) && !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 (Number(j.rc) === -3) { if (!g._spaceWarned) showToast(t("toast.nameMessage", { name: g.name, message: noSpaceText(j.need_bytes, j.free_bytes, j.space_install) })); g._spaceWarned = true; } else if (g._wasActive) { const why = j.fail_step ? ` ${failStepText(j)}` : ""; showToast(t(bytes > 0 ? "toast.downloadFailedPartial" : "toast.downloadFailed", { name: g.name, why })); if (j.fail_step) { addLog("log.fetchFailed", { id: g.title_id, step: j.fail_step, curl: j.last_curl_rc, http: j.last_http_code }); renderLogs(); } } g._wasActive = false; g.downloading = false; g.resumable = bytes > 0; g.partial_bytes = bytes || g.partial_bytes || 0; } }); } // Why the package list (manifest) couldn't be fetched, in words. function failStepText(j) { const http = Number(j.last_http_code) || 0; // "connect": DNS answered, but no address took the connection. const step = j.fail_step === "connect" ? "network" : j.fail_step; const key = `failstep.${step}`; return step && step !== "unknown" && tHas(key) ? t(key, { http }) : t("failstep.unknown", { step }); } function startInstallPolling() { if (installPollTimer) return; installPollTimer = setInterval(refreshInstallStatus, 2000); refreshInstallStatus(); } function stopInstallPolling() { if (!installPollTimer) return; clearInterval(installPollTimer); installPollTimer = null; } async function refreshInstallStatus() { let s; try { const response = await fetch(API.installStatus, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); s = await response.json(); } catch (error) { return; } if (!s || !s.active) { if (!state.titles.some((g) => g.installing)) stopInstallPolling(); return; } // Installs streamed from PatchDL: what PatchDL measured itself (how much // of each package the console read) beats Sony's status query, which fails // or reports nothing useful for them. One entry per title. const streams = Array.isArray(s.streams) ? s.streams : []; if (streams.length) { let ended = false, changed = false; streams.forEach((st) => { const game = state.titles.find((g) => g.title_id.startsWith(st.title_id)); if (!game) return; const over = st.phase === "done" || st.phase === "stalled"; if (over && !game.installing) return; // finished before this page watched it const total = Number(st.total) || 0; const read = Math.min(Number(st.read) || 0, total || Infinity); const pct = total ? Math.floor((read * 100) / total) : 0; // Re-render only on a visible change: a rebuilt card restarts the // indeterminate bar's animation. if (game.installing !== !over || game.installStatus !== st.phase || game.installProgress !== pct) changed = true; game.installing = !over; game.installStatus = st.phase; game.installProgress = pct; game.installDone = read; game.installTotal = total; if (over) { const msg = st.phase === "stalled" ? t("install.msg.stalled") : st.verified ? t(st.deleted ? "install.msg.verifiedDeleted" : "install.msg.verifiedKept") : t("install.msg.unconfirmed"); addLog("log.installEnded", { id: game.title_id, msg }); showToast(t("toast.installEnded", { name: game.name, msg })); ended = true; } }); if (changed) renderGames(); if (state.titles.some((g) => g.installing)) startInstallPolling(); if (ended) { renderLogs(); if (!state.titles.some((g) => g.installing)) stopInstallPolling(); loadInitialData({ quiet: true }); } return; } const titleId = s.target_title_id || ""; const game = state.titles.find((g) => g.title_id === titleId || g.title_id.slice(0, 9) === titleId.slice(0, 9)); if (!game) return; game.installing = !s.terminal; game.installStatus = s.status || "running"; game.installProgress = Number(s.progress) || 0; game.installDone = Number(s.downloaded_size) || 0; game.installTotal = Number(s.total_size) || 0; renderGames(); if (s.terminal) { const ok = s.status === "playable"; addLog(ok ? "log.installCompleted" : "log.installStopped", { id: game.title_id, status: s.status || t("common.unknown"), code: s.error_code ? ` (0x${Number(s.error_code >>> 0).toString(16)})` : "", }); renderLogs(); stopInstallPolling(); if (ok) loadInitialData(); } } 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(","); } // Top-of-page status banner. Visible while any job in this batch is queued, // active, or has just finished (done jobs linger one or two polls before the // pool reaps them, which is what gives us the "5 of 9" position). function renderGlobalStatus() { const el = els.globalDl; if (!el) return; const jobs = state.downloads || []; const batch = jobs.filter((j) => ["queued", "active", "done"].includes(j.state)); const flying = batch.filter((j) => j.state === "queued" || j.state === "active"); if (flying.length === 0) { el.hidden = true; return; } el.hidden = false; const active = batch.find((j) => j.state === "active") || flying[0]; const total = batch.length; const done = batch.filter((j) => j.state === "done").length; const pos = Math.min(total, done + 1); const isActive = active && active.state === "active"; // Eyebrow + name els.globalDlState.textContent = !isActive ? t("dl.queued") : active.verifying ? t("dl.verifying") : t("dl.downloading"); const activeGame = state.titles.find((g) => g.title_id === active.title_id); els.globalDlName.textContent = active.name || (activeGame && activeGame.name) || active.title_id || "—"; // Position chip ("3 of 9") only when there's more than one game in this run if (total > 1) { els.globalDlPosition.hidden = false; els.globalDlPosition.textContent = t("dl.position", { pos, total }); } else { els.globalDlPosition.hidden = true; } // Progress line const pct = pctOf(active); const speed = Number(active._speed) || 0; const done_ = Number(active.bytes) || 0; const total_ = Number(active.total_bytes) || 0; els.globalDlPct.innerHTML = isActive ? `${pct}%` : `— ${t("dl.waiting")}`; els.globalDlSpeed.innerHTML = isActive && speed > 0 ? `${formatBytes(speed)}/s` : (isActive && !total_ ? t("dl.fetchingManifest") : "—"); els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_ ? t("progress.eta", { eta: formatEta((total_ - done_) / speed) }) : ""; // Bar: animated indeterminate while fetching manifest, otherwise width=pct if (isActive && total_ > 0) { els.globalDlBar.classList.remove("is-indeterminate"); els.globalDlBar.style.width = pct + "%"; } else { els.globalDlBar.classList.add("is-indeterminate"); } } // Update the progress bar/meta in place to avoid rebuilding every card each tick. function applyDownloadProgress() { renderGlobalStatus(); 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 ---------------- */ // Labels: storage.state. in i18n.js. const STORAGE_STATE = { complete: { className: "ok" }, paused: { className: "warn" }, downloading: { className: "live" }, installing: { className: "live" }, failed: { className: "blocked" }, }; // What PatchDL keeps under /data/patchdl, one row per title folder, with a // delete button that also works for games that are no longer installed. /* ---------------- download drive ---------------- */ let drives = null; // from /api/drives: [{path, free_mb, total_mb}] function driveLabel(path) { return path === "/data" ? t("settings.driveInternal") : t("settings.driveNamed", { name: path.replace(/^\/mnt\//, "") }); } function renderDrives() { const sel = els.downloadDrive; if (!sel) return; const current = state.config.download_drive || "/data"; const list = drives || [{ path: current }]; sel.replaceChildren(...list.map((d) => { const opt = document.createElement("option"); opt.value = d.path; opt.textContent = d.free_mb != null ? `${driveLabel(d.path)} · ${t("rail.free", { space: formatMb(d.free_mb) })}` : driveLabel(d.path); opt.selected = d.path === current; return opt; })); } async function loadDrives() { const data = await fetchJson("/api/drives"); if (data && Array.isArray(data.drives)) drives = data.drives; renderDrives(); } async function loadStorage() { if (!els.storageList) return; let data; try { const response = await fetch(API.storage, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); data = await response.json(); } catch (error) { els.storageList.innerHTML = `

    ${t("storage.readFailed")}

    `; return; } const items = (data.items || []).sort((a, b) => b.bytes - a.bytes); els.storageTotal.textContent = items.length ? t("storage.total", { size: formatBytes(data.total_bytes) }) : ""; if (!items.length) { els.storageList.innerHTML = `

    ${t("storage.empty")}

    `; return; } els.storageList.replaceChildren(...items.map((item) => { const stateKey = STORAGE_STATE[item.state] ? item.state : "complete"; const info = STORAGE_STATE[stateKey]; const busy = item.state === "installing" || item.state === "downloading"; const row = document.createElement("div"); row.className = "storage-row"; row.innerHTML = ` ${escapeHtml(item.name || item.title_id)}
    ${escapeHtml(item.title_id)}${item.version ? ` · ${escapeHtml(item.version)}` : ""} · ${escapeHtml(storageRelation(item))}${item.drive && item.drive !== "/data" ? ` · ${escapeHtml(driveLabel(item.drive))}` : ""}
    ${t(`storage.state.${stateKey}`)} ${formatBytes(item.bytes)} `; const btn = document.createElement("button"); btn.type = "button"; btn.className = "row-button is-cancel"; btn.textContent = t("btn.delete"); btn.disabled = busy; btn.title = busy ? t("storage.busy") : t("storage.deleteTitle", { size: formatBytes(item.bytes), name: item.name || item.title_id }); btn.addEventListener("click", () => deleteStorage(item)); row.appendChild(btn); return row; })); } // How the package relates to the installed game, so a kept package can be // judged: still useful (newer), spent (installed), or for a game not present. function storageRelation(item) { if (!item.installed) return t("storage.rel.notInstalled"); if (!item.version || !item.installed_version) return t("storage.rel.versionUnknown"); return item.version > item.installed_version ? t("storage.rel.newer") : t("storage.rel.installed"); } async function deleteStorage(item) { const label = item.name || item.title_id; if (!window.confirm(t("storage.confirmDelete", { name: label, size: formatBytes(item.bytes) }))) return; try { await postJson(API.storageDelete(item.title_id), { drive: item.drive || "" }); showToast(t("toast.storageDeleted", { name: label })); } catch (error) { showToast(t("toast.nameMessage", { name: label, message: reasonText(error) })); } await loadStorage(); await loadInitialData({ quiet: true }); } 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(sourceLabel(type))} ${t(policy.allow_install ? "policy.installAllowed" : "policy.installBlocked")} ${t(policy.allow_download ? "policy.downloadAllowed" : "policy.downloadBlocked")} - ${escapeHtml(sourceDescription(type))} `; return row; }); els.sourcePolicyList.replaceChildren(...rows); } // Log entries keep their key and values, so the Logs view shows them in the // chosen language while the diagnostic report (pasted into issues) stays // English. function addLog(key, vars) { state.logs.push({ time: timeNow(), key, vars }); } function logLine(entry, tr) { return `[${entry.time}] ${tr(entry.key, entry.vars)}`; } function renderLogs() { els.logOutput.textContent = state.logs.length ? state.logs.map((e) => logLine(e, t)).join("\n") : t("logs.empty"); } async function saveConfig() { const config = { ...state.config, default_policy: els.defaultPolicy.value, download_drive: els.downloadDrive.value || state.config.download_drive || "/data", 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(t("toast.configSaved")); } catch (error) { state.config = config; showToast(t("toast.configLocal")); } renderSettings(); renderGames(); // install_after_download changes the button label } /* ---------------- actions (data layer) ---------------- */ // Queue a download for every game that has an available update AND could // actually be installed afterwards. Shadowmounts pass isDownloadAllowed but // fail isInstallAllowed (their app slot has no real source medium), so a // sweep would otherwise pull tens of GB that AppInstUtil will refuse — the // user picks those up by hand when the disc is ready. The server tolerates // duplicate requests, so a second click is harmless. If install_after_download // is on, the per-job auto-install pipeline kicks in once each download // finishes — no further client action needed. async function updateAll() { const targets = state.titles.filter((g) => g.status === "available" && isInstallAllowed(g) && !g.downloading && !g.downloaded); if (!targets.length) { showToast(t("toast.noUpdates")); return; } showToast(t(targets.length === 1 ? "toast.queueingOne" : "toast.queueingMany", { count: targets.length })); for (const g of targets) { // Sequential await: the pool returns quickly (202 Accepted) and we want // a stable order in the queue, not a thundering-herd of concurrent POSTs. try { await doDownload(g); } catch (_) { /* per-job errors already toast */ } } } // 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 { addLog("log.downloadQueued", { id: game.title_id, version: 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); addLog("log.downloadBlocked", { id: game.title_id, why }); showToast(t("toast.nameMessage", { name: game.name, message: why })); renderGames(); renderLogs(); } } async function doInstall(game, force = false) { const wasDownloaded = game.downloaded; game.installing = true; game.installStatus = "starting"; game.installProgress = 0; game.downloaded = false; // the package is being consumed by the install renderGames(); try { await postJson(API.action(game.title_id, "install") + (force ? "?force=1" : ""), {}); } catch (error) { game.downloaded = wasDownloaded; // refused: the package is untouched const b = (error && error.body) || {}; if (b.reason === "install_space" && !force) { // Little room on the game's drive: the console would likely stop // right after reading the header. Let the user decide. game.installing = false; renderGames(); if (window.confirm(t("install.lowSpace", { name: game.name, need: formatBytes(b.need_bytes), free: formatBytes(b.free_bytes) }))) return doInstall(game, true); addLog("log.installBlocked", { id: game.title_id, why: t("reason.install_space") }); renderLogs(); return false; } if (error.body && error.body.reason === "install_running") { // Another tab or device started it: follow that install here. startInstallPolling(); } else { game.installing = false; } const why = reasonText(error); addLog("log.installBlocked", { id: game.title_id, why }); showToast(t("toast.nameMessage", { name: game.name, message: why })); renderGames(); renderLogs(); return false; } addLog("log.installStarted", { id: game.title_id, version: game.compatible_version }); showToast(t("toast.installing", { name: game.name })); startInstallPolling(); 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(t("toast.cancelled", { name: game ? game.name : titleId })); } catch (error) { showToast(t("toast.nameMessage", { name: game ? game.name : titleId, message: reasonText(error) })); return; // refused (e.g. the console is installing from it): nothing changed } if (game) { game.downloading = false; game._localDownloading = false; game.downloaded = false; game.resumable = false; game.partial_bytes = 0; game.manual_version = null; // the server drops the pasted link on cancel } state.downloads = state.downloads.filter((i) => i.title_id !== titleId); addLog("log.cancelled", { id: 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); else if (action === "guide") openGuide(game); } // 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(t("toast.pausing", { name: game.name })); } catch (error) { showToast(t("toast.nameMessage", { name: game.name, message: 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(() => { addLog(wantEnabled ? "log.savedEnabled" : "log.savedDisabled", { id: titleId }); renderLogs(); }) .catch(() => showToast(t("toast.toggleFailed", { id: titleId }))); } /* ---------------- policy helpers ---------------- */ function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; } function isDownloadAllowed(game) { return Boolean( game.enabled !== false && (game.compatible_version || game.manual_version) && game.patch_title_match !== false && sourcePolicy(game).allow_download ); } function isInstallAllowed(game) { return Boolean( game.enabled !== false && (game.compatible_version || game.manual_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; } // Server refusal reasons are translated as reason. in i18n.js. // The server keeps 2 GB free on /data on top of the package itself. // install: the download fit, but installing it afterwards wouldn't. function noSpaceText(need, free, install) { return t(install ? "reason.noSpaceInstall" : "reason.noSpace", { need: formatBytes(need), free: formatBytes(free) }); } function reasonText(error) { const r = error && error.body && error.body.reason; return (r && r !== "fallback" && r !== "noSpace" && tHas(`reason.${r}`) ? t(`reason.${r}`) : "") || (error && error.message) || t("reason.fallback"); } /* ---------------- QR code (byte mode, ECC level M, versions 1-6) ---------------- Enough for ~100 bytes, i.e. any PatchDL link. Follows ISO/IEC 18004; the structure mirrors Project Nayuki's reference encoder. */ const QR_ECC_M = [0, 10, 16, 26, 18, 24, 16]; // ECC codewords per block const QR_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4]; // number of blocks function qrRawModules(ver) { let n = (16 * ver + 128) * ver + 64; if (ver >= 2) { const align = Math.floor(ver / 7) + 2; n -= (25 * align - 10) * align - 55; } return n; } function qrDataCodewords(ver) { return Math.floor(qrRawModules(ver) / 8) - QR_ECC_M[ver] * QR_BLOCKS_M[ver]; } function qrGfMul(x, y) { let z = 0; for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; } return z; } function qrRsDivisor(degree) { const r = new Array(degree - 1).fill(0); r.push(1); let root = 1; for (let i = 0; i < degree; i++) { for (let j = 0; j < r.length; j++) { r[j] = qrGfMul(r[j], root); if (j + 1 < r.length) r[j] ^= r[j + 1]; } root = qrGfMul(root, 0x02); } return r; } function qrRsRemainder(data, divisor) { const r = divisor.map(() => 0); for (const b of data) { const factor = b ^ r.shift(); r.push(0); divisor.forEach((coef, i) => { r[i] ^= qrGfMul(coef, factor); }); } return r; } // Returns a square boolean matrix (true = dark), or null if text is too long. function qrEncode(text) { const bytes = Array.from(new TextEncoder().encode(text)); let ver = 1; while (ver <= 6 && 4 + 8 + bytes.length * 8 > qrDataCodewords(ver) * 8) ver++; if (ver > 6) return null; // Data bit stream: byte mode, 8-bit length, payload, terminator, padding. const bits = []; const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); }; push(0x4, 4); push(bytes.length, 8); bytes.forEach((b) => push(b, 8)); const cap = qrDataCodewords(ver) * 8; push(0, Math.min(4, cap - bits.length)); push(0, (8 - (bits.length % 8)) % 8); for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) push(pad, 8); const data = []; for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2)); // Split into blocks, add Reed-Solomon ECC, interleave. const numBlocks = QR_BLOCKS_M[ver]; const eccLen = QR_ECC_M[ver]; const rawCodewords = Math.floor(qrRawModules(ver) / 8); const numShort = numBlocks - (rawCodewords % numBlocks); const shortLen = Math.floor(rawCodewords / numBlocks); const divisor = qrRsDivisor(eccLen); const blocks = []; for (let i = 0, k = 0; i < numBlocks; i++) { const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1)); k += dat.length; const ecc = qrRsRemainder(dat, divisor); if (i < numShort) dat.push(0); blocks.push(dat.concat(ecc)); } const codewords = []; for (let i = 0; i < blocks[0].length; i++) blocks.forEach((blk, j) => { if (i !== shortLen - eccLen || j >= numShort) codewords.push(blk[i]); }); // Function patterns. const size = ver * 4 + 17; const mod = Array.from({ length: size }, () => new Array(size).fill(false)); const fn = Array.from({ length: size }, () => new Array(size).fill(false)); const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; }; for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); } [[3, 3], [size - 4, 3], [3, size - 4]].forEach(([cx, cy]) => { for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) { const x = cx + dx, y = cy + dy; if (x < 0 || x >= size || y < 0 || y >= size) continue; const d = Math.max(Math.abs(dx), Math.abs(dy)); set(x, y, d !== 2 && d !== 4); } }); if (ver >= 2) { const pos = [6, size - 7]; // versions 2-6 have one alignment pattern const c = pos[1]; for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(c + dx, c + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); } const drawFormat = (mask) => { const data5 = (0 << 3) | mask; // ECC level M = 0b00 let rem = data5; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); const f = ((data5 << 10) | rem) ^ 0x5412; const bit = (i) => ((f >>> i) & 1) === 1; for (let i = 0; i <= 5; i++) set(8, i, bit(i)); set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8)); for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i)); for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i)); for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i)); set(8, size - 8, true); }; drawFormat(0); // reserve the format areas before placing data // Data placement: two-column zigzag from the bottom right. let i = 0; for (let right = size - 1; right >= 1; right -= 2) { if (right === 6) right = 5; for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) { const x = right - j; const upward = ((right + 1) & 2) === 0; const y = upward ? size - 1 - vert : vert; if (!fn[y][x] && i < codewords.length * 8) { mod[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1; i++; } } } const maskFn = [ (x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0, (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0, (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0, ]; const applyMask = (m) => { for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && maskFn[m](x, y)) mod[y][x] = !mod[y][x]; }; // Simplified penalty (runs, 2x2 blocks, balance) to pick a readable mask. const penalty = () => { let p = 0, dark = 0; for (let a = 0; a < size; a++) { let runR = 1, runC = 1; for (let b = 0; b < size; b++) { if (mod[a][b]) dark++; if (b > 0) { runR = mod[a][b] === mod[a][b - 1] ? runR + 1 : 1; runC = mod[b][a] === mod[b - 1][a] ? runC + 1 : 1; if (runR === 5) p += 3; else if (runR > 5) p++; if (runC === 5) p += 3; else if (runC > 5) p++; } if (a > 0 && b > 0) { const c = mod[a][b]; if (c === mod[a - 1][b] && c === mod[a][b - 1] && c === mod[a - 1][b - 1]) p += 3; } } } const total = size * size; p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10; return p; }; let best = 0, bestScore = Infinity; for (let m = 0; m < 8; m++) { applyMask(m); drawFormat(m); const score = penalty(); if (score < bestScore) { best = m; bestScore = score; } applyMask(m); // XOR again to undo } applyMask(best); drawFormat(best); return mod; } // SVG markup for a QR matrix, dark on white with the required 4-module margin. function qrSvg(matrix) { const n = matrix.length, q = 4, s = n + q * 2; let d = ""; matrix.forEach((row, y) => row.forEach((dark, x) => { if (dark) d += `M${x + q},${y + q}h1v1h-1z`; })); return ``; } /* ---------------- older patch guide (Prospero) ---------------- */ // Sony's version.xml only lists the newest patch. When that needs newer // firmware, Prospero Patches still has older ones, but its links sit behind a // human check, so the user copies one link and PatchDL does the rest. Copying // is awkward in the PS5 browser, hence the QR hand-off to a phone. const guide = { el: null, game: null, pick: null, fromLink: false, started: false, busy: false, lastUrl: "", timer: null, returnFocus: null, onKey: null }; // The version the guide fetches: the older-firmware patch Prospero found, or // one the user picked under "Other versions" (Prospero for PS5, Orbis for PS4). function pickFor(game, version, size) { const ps4 = isPs4(game); const site = ps4 ? "Orbis Patches" : "Prospero Patches"; const page = `https://${ps4 ? "orbispatches.com" : "prosperopatches.com"}/${encodeURIComponent(game.title_id.slice(0, 9))}`; if (version) return { version, size: size || "", fw: "", site, page, ps4, chosen: true }; return { version: game.prospero_version, size: game.prospero_size || "", fw: game.prospero_required_fw || "", site, page, ps4, chosen: false }; } // No download or install in the way, and downloads allowed for this game. function canPickVersion(game, version) { return !game.downloading && !game.downloaded && !game.resumable && !game.installing && isDownloadAllowed({ ...game, manual_version: version }); } function canGetOlderPatch(game) { return hasOlderPatch(game) && !game.manual_version && !game.downloading && !game.downloaded && !game.resumable && !game.installing && isDownloadAllowed({ ...game, manual_version: game.prospero_version }); } // A link a phone can open. A page opened on the PS5 itself runs on 127.0.0.1, // so the server reports its LAN address for this. function phoneLink(game, pick) { const h = location.hostname; let base = location.origin; if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") { if (!state.status.lan_ip) return null; base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`; } const extra = pick && pick.chosen ? `/${encodeURIComponent(pick.version)}/${encodeURIComponent(pick.size)}` : ""; return `${base}/#get/${encodeURIComponent(game.title_id)}${extra}`; } function openGuideFromHash() { const m = location.hash.match(/^#get\/([A-Za-z0-9_.-]+)(?:\/([0-9.]+)\/([A-Za-z0-9. %]*))?$/); if (!m) return; const game = state.titles.find((g) => g.title_id === m[1]); if (!game) { showToast(t("toast.notInstalled")); clearGuideHash(); return; } if (m[2]) { // a version picked under "Other versions" on the console let size = m[3] || ""; try { size = decodeURIComponent(size); } catch (_) { /* keep it as it came */ } const pick = pickFor(game, m[2], size); if (!canPickVersion(game, pick.version)) { showToast(t("toast.alreadyOnWay", { name: game.name })); clearGuideHash(); return; } openGuide(game, true, pick); return; } if (!canGetOlderPatch(game)) { showToast(game.manual_version || game.downloading || game.downloaded ? t("toast.olderOnWay", { name: game.name }) : t("toast.noOlderPatch", { name: game.name })); clearGuideHash(); return; } openGuide(game, true); } function clearGuideHash() { if (location.hash.startsWith("#get/")) history.replaceState(null, "", location.pathname + location.search); } // The PS5 browser can't copy links, so on the console the guide only hands // over to a phone. Opened via the home-screen tile the page runs on 127.0.0.1. function isConsoleBrowser() { const h = location.hostname; return /PlayStation/i.test(navigator.userAgent) || h === "127.0.0.1" || h === "localhost"; } function openGuide(game, fromLink = false, pick = null) { closeGuide(); pick = pick || pickFor(game); Object.assign(guide, { game, pick, fromLink, started: false, busy: false, lastUrl: "", returnFocus: document.activeElement }); const ver = escapeHtml(pick.version); const size = pick.size ? ` (${escapeHtml(pick.size)})` : ""; const site = escapeHtml(pick.site); const onConsole = !fromLink && isConsoleBrowser(); const link = fromLink ? null : phoneLink(game, pick); const matrix = link ? qrEncode(link) : null; const head = `

    ${t(pick.chosen ? "guide.eyebrowOther" : "guide.eyebrowOld")}

    ${t("guide.title", { version: ver })}

    ${escapeHtml(game.name)}

    ${closeButtonHtml()}
    `; let body; if (onConsole) { body = matrix ? `
    ${qrSvg(matrix)}

    ${t("guide.scanConsole")}

    ${escapeHtml(link)}
    ` : `

    ${t("guide.openOnPhone", { port: escapeHtml(String(state.status.http_port || 12880)) })}

    `; body += `
    `; } else { const handoff = matrix ? `
    ${qrSvg(matrix)}
    ${t("guide.easier")} ${t("guide.easierText")} ${escapeHtml(link)}
    ` : ""; const why = pick.chosen ? t("guide.whyChosen", { site, version: ver, size }) : t("guide.whyOld", { latest: escapeHtml(game.latest_version || t("guide.newestPatch")), fw: escapeHtml(game.latest_required_fw || "?"), version: ver, pickFw: escapeHtml(pick.fw || "?"), }); const copyStep = pick.ps4 ? `${t("guide.copyLinks")} ${t("guide.copyLinksText")}` : `${t("guide.copySc")} ${t("guide.copyScText")}`; body = `

    ${why}

    ${handoff}
    1. ${t("guide.openSite", { site })} ${t("guide.tapVersion", { version: ver, size })}
      ${t("guide.openSite", { site: pick.ps4 ? "Orbis" : "Prospero" })}
    2. ${copyStep}
    3. ${t("guide.paste")} ${t("guide.pasteText")}
    `; } const wrap = document.createElement("div"); wrap.className = "guide-backdrop"; wrap.innerHTML = ``; document.body.appendChild(wrap); guide.el = wrap; const input = wrap.querySelector(".guide-input"); if (input) { const tryInput = () => { const url = extractLink(input.value); if (url && url !== guide.lastUrl && /\.(pkg|crc|json)$/i.test(url)) guideSubmit(url, false); }; input.addEventListener("focus", () => input.select()); // a paste then replaces the old link input.addEventListener("input", tryInput); input.addEventListener("paste", () => setTimeout(tryInput, 0)); wrap.querySelector(".guide-start").addEventListener("click", () => guideSubmit(extractLink(input.value), false)); } wrap.querySelector(".guide-close").addEventListener("click", closeGuide); wrap.addEventListener("click", (e) => { if (e.target === wrap) closeGuide(); }); guide.onKey = (e) => { if (e.key === "Escape") closeGuide(); }; document.addEventListener("keydown", guide.onKey); guideStatus("idle", matrix ? t("guide.waitingPhone") : ""); guide.timer = setInterval(guidePoll, 2000); (fromLink ? wrap.querySelector(".guide-open") : wrap.querySelector(".guide-close")).focus(); } function closeGuide() { if (!guide.el) return; clearInterval(guide.timer); document.removeEventListener("keydown", guide.onKey); guide.el.remove(); guide.el = null; guide.game = null; clearGuideHash(); if (guide.returnFocus && document.contains(guide.returnFocus)) guide.returnFocus.focus(); } // The last link in the field wins, so pasting a new link next to a rejected // one (instead of over it) still picks up the new one. function extractLink(text) { const all = String(text || "").match(/https?:\/\/(?:(?!https?:\/\/)[^\s"'<>])+/g) || []; const pkgs = all.filter((u) => /\.(pkg|crc|json)$/i.test(u)); return (pkgs.length ? pkgs[pkgs.length - 1] : all[all.length - 1]) || ""; } function guideStatus(kind, message, action) { if (!guide.el) return; const box = guide.el.querySelector(".guide-status"); box.innerHTML = message ? `

    ${message}

    ` : ""; if (action) { const b = document.createElement("button"); b.type = "button"; b.className = "row-button is-ghost guide-action"; b.textContent = action.label; b.addEventListener("click", action.run); box.appendChild(b); } } async function guideSubmit(url, force) { const game = guide.game; if (!game || guide.busy) return; const pick = guide.pick; if (!url) { guideStatus("error", t(pick.ps4 ? "guide.pasteOrbis" : "guide.pasteSc")); return; } guide.busy = true; guide.lastUrl = url; const start = guide.el.querySelector(".guide-start"); start.disabled = true; guideStatus("busy", t("guide.checkingLink")); try { await postJson("/api/download_url", { title_id: game.title_id, url, version: pick.version, size: pick.size, force, }); } catch (error) { guide.busy = false; if (!guide.el) return; start.disabled = false; const b = (error && error.body) || {}; if (b.reason === "no_space") { guideStatus("error", escapeHtml(noSpaceText(b.need_bytes, b.free_bytes))); } else if (b.reason === "size_mismatch") { guideStatus("warn", t("guide.sizeMismatch", { found: formatBytes(b.found_bytes), pieces: Number(b.pieces) || "?", version: escapeHtml(pick.version), expected: escapeHtml(b.expected || "?"), }), { label: t("guide.downloadAnyway"), run: () => guideSubmit(url, true) }); } else if (pick.ps4 && (b.reason === "not_an_sc_link" || b.reason === "url_not_allowed")) { // Orbis offers piece links, not Prospero's SC link guideStatus("error", escapeHtml(t("reason.notOrbisLink"))); } else { guideStatus("error", escapeHtml(reasonText(error)) + (b.detail ? ` (${escapeHtml(b.detail)})` : "")); } addLog("log.linkRefused", { site: pick.site, id: game.title_id, why: reasonText(error) }); renderLogs(); return; } guide.busy = false; manualDownloadStarted(game, pick.version); if (!guide.fromLink) { showToast(t("toast.downloadStarted", { name: game.name, version: pick.version })); closeGuide(); return; } guide.started = true; guideStatus("ok", t("guide.started")); } // Card state for a download that the server has accepted (from this page or // from a phone); the pool's job list takes over from here. function manualDownloadStarted(game, version) { game.manual_version = version; game.downloading = true; game._localDownloading = true; game._localSince = Date.now(); 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.manual_version, state: "queued", progress: 0, bytes: 0, total_bytes: 0 }); addLog("log.downloadQueuedFrom", { site: isPs4(game) ? "Orbis" : "Prospero", id: game.title_id, version: game.manual_version }); renderGames(); renderLogs(); startDownloadPolling(); } async function guidePoll() { const game = guide.game; if (!game) return; let jobs; try { const r = await fetch(API.downloads, { cache: "no-store" }); if (!r.ok) return; jobs = await r.json(); } catch (error) { return; } if (guide.game !== game) return; const j = (jobs || []).find((x) => x.title_id === game.title_id); if (!j) return; if (!guide.started) { // Only a job that is actually running means a link arrived from elsewhere; // an old failed or paused entry does not. if (j.state !== "queued" && j.state !== "active") return; manualDownloadStarted(game, guide.pick.version); if (!guide.fromLink) { showToast(t("toast.linkReceived", { name: game.name, version: guide.pick.version })); closeGuide(); return; } guide.started = true; } const total = Number(j.total_bytes) || 0; const pct = total ? Math.min(100, Math.round((Number(j.bytes) || 0) * 100 / total)) : 0; const note = { queued: () => t("guide.note.queued"), active: () => t("guide.note.active"), paused: () => t("guide.note.paused"), done: () => t(state.config.install_after_download ? "guide.note.doneInstall" : "guide.note.doneManual"), error: () => t("guide.note.error"), }[j.state]; const noteText = note ? note() : ""; guideStatus(j.state === "error" ? "error" : "ok", `${noteText}` + `${total ? `${formatBytes(j.bytes)} / ${formatBytes(total)} · ` : ""}${pct}%`); } /* ---------------- 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 t("fmt.etaSec", { s }); const m = Math.floor(s / 60); if (m < 60) return t("fmt.etaMin", { m, s: s % 60 }); const h = Math.floor(m / 60); return t("fmt.etaHour", { h, m: m % 60 }); } 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); }