// ==UserScript== // @name Pegasus DL Firmware Filter // @namespace klept0 // @author klept0 // @license MIT // @description Adds a console firmware picker and firmware sort to the Pegasus DL (github.com/pegasus-ps5/pegasus-dl) store and to pegasus-catalog.fly.dev, shows each package's minimum firmware on its card, and adds a "Send to PS5" button on the catalog site that opens the package in your PS5's Pegasus DL. Also adds the PS4 FPKG Collection catalog (github.com/M3hmetSa1t/pegasus-ps4-collection-catalog) to the catalog site. // @include /^https?:\/\/[^/]+:6970\/.*$/ // @match https://pegasus-catalog.fly.dev/* // @version 1.3.0 // @grant none // @inject-into page // @run-at document-start // @homepageURL https://github.com/klept0/Useful_Userscripts // @downloadURL https://raw.githubusercontent.com/klept0/Useful_Userscripts/master/Gaming%20Stuff/Pegasus%20DL%20Firmware%20Filter.user.js // @updateURL https://raw.githubusercontent.com/klept0/Useful_Userscripts/master/Gaming%20Stuff/Pegasus%20DL%20Firmware%20Filter.user.js // ==/UserScript== /* global state, renderLibraryResults, renderPackageDetail, resetLibraryVisibleCount */ (function () { 'use strict'; const STORAGE_KEY = 'pegasusFwFilter'; const MAX_FW = 13; // The user picks the major firmware their console is on; packages whose // minimum firmware is at or below it should work. Options are grouped by // the ranges the scene uses for public jailbreaks (UMTX up to 5.50, // Y2JB + Lapse up to 10.01, Y2JB + P2JB up to 12.70, Relapse up to 13.60). const FW_RANGES = [ { min: 1, max: 5, label: '1.xx–5.xx (UMTX)' }, { min: 6, max: 10, label: '6.xx–10.xx (Lapse)' }, { min: 11, max: 12, label: '11.xx–12.xx (P2JB)' }, { min: 13, max: 13, label: '13.xx (Relapse)' } ]; const UNKNOWN = Infinity; // PS4 FPKGs (CUSA title IDs) state a PS4 system firmware, which says // nothing about the PS5 firmware they need, so they are never hidden by // the PS5 firmware picker and sort after PS5 packages. const PS4 = 0; // The catalog site's "Send to PS5" button opens Pegasus DL with this hash. const OPEN_HASH = '#pegasus-open='; const prefs = loadPrefs(); const fwCache = new WeakMap(); // Catalogs have no firmware field, so the minimum firmware is read from the // description and link names. Seen formats: // DLPS: "Works on 6.xx and higher", "Works on 13.60 and higher", "EUR 6.xx Base" // PFS: "Firmware: 10.xx+, 11.xx+, 12.xx+" // Pippo: "Base FPKG & Dump are 12.xx, separated BackPort for 4.xx+ included" // ZER0: "Firmware: 4.xx BackPort" // The lowest firmware mentioned is the lowest any offered build runs on. // "FPKG ... up to 11.60" is an upper bound, so it is removed first. function minFirmware(pkg) { if (fwCache.has(pkg)) return fwCache.get(pkg); if (/^CUSA/i.test(pkg.titleId || '')) { fwCache.set(pkg, PS4); return PS4; } const links = (pkg.downloadLinks || []).map((link) => link.name || '').join('\n'); const text = `${pkg.description || ''}\n${links}`.replace(/up to \d{1,2}\.\d+/gi, ''); const found = []; const patterns = [ /(? { for (const match of text.matchAll(pattern)) { const major = Number(match[1]); if (major >= 1 && major <= MAX_FW) found.push(major); } }); const fw = found.length ? Math.min(...found) : UNKNOWN; fwCache.set(pkg, fw); return fw; } function prefsActive() { return !!(prefs.maxFw || prefs.sort); } // PS4 and unknown firmware always sort last; Array.prototype.sort is stable, // so the catalog order is kept within each firmware. function applyPrefs(packages) { let result = prefs.maxFw ? packages.filter((pkg) => minFirmware(pkg) <= prefs.maxFw) : packages.slice(); if (prefs.sort) { const direction = prefs.sort === 'asc' ? 1 : -1; result = result.sort((left, right) => { const a = minFirmware(left); const b = minFirmware(right); if (a === b) return 0; const aLast = a === UNKNOWN || a === PS4; const bLast = b === UNKNOWN || b === PS4; if (aLast !== bLast) return aLast ? 1 : -1; if (aLast) return a === PS4 ? -1 : 1; return (a - b) * direction; }); } return result; } function badgeText(pkg) { const fw = minFirmware(pkg); if (fw === PS4) return 'PS4'; return fw === UNKNOWN ? '' : `FW ${fw}.xx+`; } function createControls(onChange) { const fwOptions = [ '', ...FW_RANGES.map((range) => { const options = []; for (let fw = range.min; fw <= range.max; fw += 1) { options.push(``); } return `${options.join('')}`; }) ]; const wrap = document.createElement('div'); wrap.className = 'pegasus-fw-controls'; wrap.innerHTML = ` `; wrap.querySelector('[data-fw="maxFw"]').value = String(prefs.maxFw); wrap.querySelector('[data-fw="sort"]').value = prefs.sort; wrap.addEventListener('change', (event) => { const select = event.target.closest('[data-fw]'); if (!select) return; prefs[select.dataset.fw] = select.dataset.fw === 'maxFw' ? Number(select.value) : select.value; savePrefs(); onChange(); }); return wrap; } function loadPrefs() { const defaults = { maxFw: 0, sort: '' }; try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); const maxFw = Number(saved.maxFw); return { maxFw: Number.isInteger(maxFw) && maxFw >= 0 && maxFw <= MAX_FW ? maxFw : defaults.maxFw, sort: saved.sort === 'asc' || saved.sort === 'desc' ? saved.sort : defaults.sort }; } catch (error) { return defaults; } } function savePrefs() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch (error) { // Storage unavailable (private window): settings last for this page only. } } function addStyle(css) { const style = document.createElement('style'); style.textContent = css; (document.head || document.documentElement).appendChild(style); } // --------------------------------------------------------------------------- // Pegasus DL web UI on the PS5 (port 6970) // --------------------------------------------------------------------------- function initPegasusDl() { // Pegasus DL keeps its store state and render functions as page globals. // Bail out quietly on any other page served from port 6970. if (typeof state === 'undefined' || typeof window.filteredPackages !== 'function') return; // filteredPackages() is the single source for the result list, count label // and "load more" paging, so filtering and sorting here covers all of them. // The renderer compares array identity, so the same array is returned until // the underlying list or the firmware settings change. const originalFilteredPackages = window.filteredPackages; let cached = null; window.filteredPackages = function () { const base = originalFilteredPackages.apply(this, arguments); if (!prefsActive()) return base; if (cached && cached.base === base && cached.maxFw === prefs.maxFw && cached.sort === prefs.sort) { return cached.packages; } cached = { base, maxFw: prefs.maxFw, sort: prefs.sort, packages: applyPrefs(base) }; return cached.packages; }; const originalPackageCards = window.packageCards; window.packageCards = function (packages) { const cards = originalPackageCards.apply(this, arguments); return cards.map((html, index) => { const text = badgeText(packages[index]); if (!text) return html; // The title ID line has spare width; the source line truncates. return html.replace( /(
[^<]*)<\/div>/, `$1${text}
` ); }); }; const originalRenderLibraryPanel = window.renderLibraryPanel; window.renderLibraryPanel = function () { const result = originalRenderLibraryPanel.apply(this, arguments); injectControls(); return result; }; function injectControls() { const actions = document.querySelector('#homeLibrary .library-toolbar-actions'); if (!actions || actions.querySelector('.pegasus-fw-controls')) return; actions.prepend(createControls(rerender)); } function rerender() { resetLibraryVisibleCount(); const library = document.querySelector('#homeLibrary'); if (library) library.scrollTop = 0; renderLibraryResults('homeLibrary', true); renderPackageDetail('homeDetail'); } addStyle(` .pegasus-fw-controls { display: flex; gap: 8px; align-items: center; } .pegasus-fw-select { appearance: none; background: var(--surface); border: 1px solid var(--line); border-radius: 999px; color: var(--text); cursor: pointer; font: inherit; font-size: 12px; min-height: 30px; padding: 4px 12px; } .pegasus-fw-select:hover, .pegasus-fw-select:focus-visible { border-color: var(--primary-container); } .pegasus-fw-badge { border: 1px solid var(--primary-container); border-radius: 999px; color: var(--primary); font-family: inherit; font-size: 11px; margin-left: 6px; padding: 0 6px; white-space: nowrap; } `); // Opens a package sent from the catalog site: shows it in the Store tab // with its download links, so the normal Pegasus DL download flow // (provider resolving, debrid, PS5 browser capture) runs from here. async function openFromHash() { if (!location.hash.startsWith(OPEN_HASH)) return; let request; try { request = JSON.parse(decodeURIComponent(location.hash.slice(OPEN_HASH.length))); } catch (error) { return; } history.replaceState(null, '', location.pathname + location.search); if (!request || typeof request !== 'object') return; const deadline = Date.now() + 30000; while ((state.libraryLoading || !state.packages.length) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)); } const title = String(request.title || ''); const titleId = String(request.titleId || ''); const source = state.sources.find((item) => item.url === request.catalog); const candidates = source ? state.packages.filter((pkg) => Number(pkg.sourceId) === Number(source.id)) : state.packages; const pkg = candidates.find((item) => item.titleId === titleId && item.title === title) || candidates.find((item) => titleId && item.titleId === titleId) || candidates.find((item) => item.title === title); if (!pkg) { setMessage(source ? `${title || 'Package'} is not in ${source.name} yet. Refresh the source and try again.` : `${title || 'Package'} was not found. Add its catalog as a source first.`); return; } switchView('store'); state.search = pkg.titleId || pkg.title || ''; state.librarySearchQuery = state.search.trim().toLowerCase(); state.sourceFilter = Number(pkg.sourceId); state.selectedPackageId = pkg.id; state.detailExpanded = true; resetLibraryVisibleCount(); renderLibraryPanel('homeLibrary', true); renderPackageDetail('homeDetail'); setMessage(`Opened ${pkg.title}. Pick a download link.`); } window.addEventListener('hashchange', openFromHash); // The store may already be rendered by the time this runs. injectControls(); if (prefsActive()) rerender(); openFromHash(); } // --------------------------------------------------------------------------- // pegasus-catalog.fly.dev // --------------------------------------------------------------------------- // The catalog site is a React app that pages packages from the server // (/api/catalogs//packages?query=&page=N, 48 per page) into an // infinite list. While a firmware filter or sort is set, page requests are // answered from the full catalog (fetched once and cached briefly), filtered // and sorted, so the app's own paging, search and infinite scroll keep // working over the whole catalog. // Catalogs that are not on pegasus-catalog.fly.dev but are published as a // Pegasus DL catalog JSON. They are added to the site's catalog list and // served from their JSON, so they browse, search and copy like the others. const EXTRA_CATALOGS = [ { slug: 'ps4-fpkg-collection', name: 'PS4 FPKG Collection', // github.com/M3hmetSa1t/pegasus-ps4-collection-catalog (MIT) sourceUrl: 'https://m3hmetsa1t.github.io/pegasus-ps4-collection-catalog/pegasus-ps4-catalog.json' } ]; function initCatalogSite() { const PAGE_PATH = /^\/api\/catalogs\/([^/]+)\/packages$/; const CATALOGS_PATH = '/api/catalogs'; const COPIES_PATH = /^\/api\/catalogs\/([^/]+)\/copies$/; const PAGE_SIZE = 48; const EXTRA_TTL_MS = 10 * 60000; // Synthetic package ids for extra catalogs, well above the site's own. const EXTRA_ID_BASE = 2000000000; const FULL_LIST_TTL_MS = 60000; const FETCH_CONCURRENCY = 6; const PS5_HOST_KEY = 'pegasusFwPs5Host'; const DEFAULT_PORT = 6970; const nativeFetch = window.fetch.bind(window); const fullLists = new Map(); // Packages as last served for the current catalog and search, by page, // used to put badges on the rendered cards in the same order. let view = { key: '', slug: '', pages: [], total: 0 }; // The package last clicked in the grid, for the detail pane's Send button. let selected = null; // Source URL per catalog slug, from the catalog list, for Send to PS5. const sourceUrls = new Map(EXTRA_CATALOGS.map((catalog) => [catalog.slug, catalog.sourceUrl])); const extraLoads = new Map(); window.fetch = function (input, init) { const method = ((init && init.method) || (input instanceof Request ? input.method : 'GET')).toUpperCase(); const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url, location.href); if (url.origin !== location.origin) return nativeFetch(input, init); if (method === 'GET' && url.pathname === CATALOGS_PATH) { return serveCatalogs(input, init); } const copies = method === 'POST' && COPIES_PATH.exec(url.pathname); if (copies && extraCatalog(decodeURIComponent(copies[1]))) { // The site counts copies on its server; extra catalogs have no counter. return Promise.resolve(jsonResponse({ copyCount24h: 0 })); } const match = method === 'GET' && PAGE_PATH.exec(url.pathname); if (!match) return nativeFetch(input, init); const slug = decodeURIComponent(match[1]); // Extra catalogs have no server side to fall back to. if (extraCatalog(slug)) return servePage(slug, url); // Any failure falls back to the site's normal request. return servePage(slug, url).catch(() => nativeFetch(input, init)); }; function jsonResponse(body) { return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); } function extraCatalog(slug) { return EXTRA_CATALOGS.find((catalog) => catalog.slug === slug) || null; } // Appends the extra catalogs to the site's list. One that fails to load is // left out rather than shown empty. async function serveCatalogs(input, init) { const response = await nativeFetch(input, init); if (!response.ok) return response; const data = await response.clone().json(); const catalogs = Array.isArray(data.catalogs) ? data.catalogs : []; catalogs.forEach((catalog) => sourceUrls.set(catalog.slug, catalog.sourceUrl)); const extras = await Promise.all(EXTRA_CATALOGS.map(async (catalog, index) => { try { const loaded = await loadExtra(catalog); return { id: EXTRA_ID_BASE + index, slug: catalog.slug, name: catalog.name, packageCount: loaded.packages.length, lastUpdatedAt: loaded.updatedAt, sourceUrl: catalog.sourceUrl, copyCount24h: 0 }; } catch (error) { return null; } })); return jsonResponse({ ...data, catalogs: [...catalogs, ...extras.filter(Boolean)] }); } function loadExtra(catalog) { const entry = extraLoads.get(catalog.slug); if (entry && Date.now() - entry.at < EXTRA_TTL_MS) return entry.promise; const promise = (async () => { const response = await nativeFetch(catalog.sourceUrl); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); const modified = Date.parse(response.headers.get('Last-Modified') || ''); const base = EXTRA_ID_BASE + EXTRA_CATALOGS.indexOf(catalog) * 1000000; return { updatedAt: new Date(Number.isNaN(modified) ? Date.now() : modified).toISOString(), packages: (Array.isArray(data.packages) ? data.packages : []).map((pkg, index) => ({ ...pkg, id: base + index, catalogSlug: catalog.slug })) }; })(); extraLoads.set(catalog.slug, { at: Date.now(), promise }); promise.catch(() => extraLoads.delete(catalog.slug)); return promise; } async function servePage(slug, url) { const query = url.searchParams.get('query') || ''; const page = Math.max(1, Number(url.searchParams.get('page')) || 1); if (!prefsActive() && !extraCatalog(slug)) { const response = await nativeFetch(url.href); if (response.ok) { const data = await response.clone().json(); record(slug, query, page, data.packages || [], data.total); } return response; } const all = await fullList(slug, query); const packages = prefsActive() ? applyPrefs(all.packages) : all.packages; const start = (page - 1) * all.pageSize; const slice = packages.slice(start, start + all.pageSize); record(slug, query, page, slice, packages.length); return jsonResponse({ total: packages.length, page, pageSize: all.pageSize, packages: slice }); } function fullList(slug, query) { const key = `${slug}\n${query}`; const entry = fullLists.get(key); if (entry && Date.now() - entry.at < FULL_LIST_TTL_MS) return entry.promise; const promise = loadFullList(slug, query); fullLists.set(key, { at: Date.now(), promise }); promise.catch(() => fullLists.delete(key)); return promise; } async function loadFullList(slug, query) { const extra = extraCatalog(slug); if (extra) { // Same matching as a plain text search: title or title ID contains it. const needle = query.trim().toLowerCase(); const { packages } = await loadExtra(extra); return { pageSize: PAGE_SIZE, packages: needle ? packages.filter((pkg) => `${pkg.title || ''} ${pkg.titleId || ''}`.toLowerCase().includes(needle)) : packages }; } const fetchPage = async (page) => { const url = `/api/catalogs/${encodeURIComponent(slug)}/packages?query=${encodeURIComponent(query)}&page=${page}`; const response = await nativeFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); }; const first = await fetchPage(1); const pageSize = first.pageSize || first.packages.length || PAGE_SIZE; const pageCount = Math.ceil((first.total || 0) / pageSize); const pages = [first.packages]; let next = 2; const worker = async () => { while (next <= pageCount) { const page = next++; pages[page - 1] = (await fetchPage(page)).packages; } }; await Promise.all(Array.from({ length: FETCH_CONCURRENCY }, worker)); return { pageSize, packages: pages.flat() }; } function record(slug, query, page, packages, total) { const key = `${slug}\n${query}\n${prefs.maxFw}\n${prefs.sort}`; if (view.key !== key) view = { key, slug, pages: [], total: 0 }; view.pages[page - 1] = packages; view.total = Number(total) || 0; scheduleSync(); } function refetch() { view = { key: '', slug: view.slug, pages: [], total: 0 }; // The app uses TanStack Query with its default refetch-on-focus, so a // visibilitychange makes it reload the visible list through the hook above. document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('visibilitychange')); const grid = document.querySelector('.package-grid'); if (grid && grid.parentElement) grid.parentElement.scrollTop = 0; } let syncQueued = false; function scheduleSync() { if (syncQueued) return; syncQueued = true; requestAnimationFrame(() => { syncQueued = false; sync(); }); } // Re-applies controls, count and badges after React renders. Only writes // when something differs, so its own changes do not loop. function sync() { // Own full-width row at the end of the header, so the Copy Source URL // button keeps its place at narrow widths. const header = document.querySelector('.catalog-header'); if (header && !header.querySelector('.pegasus-fw-controls')) { const controls = createControls(refetch); controls.insertAdjacentHTML('beforeend', ` `); const hostInput = controls.querySelector('.pegasus-fw-ps5'); hostInput.value = loadPs5Host(); hostInput.addEventListener('change', () => { hostInput.value = hostInput.value.trim(); hostInput.toggleAttribute('aria-invalid', !!hostInput.value && !ps5Origin(hostInput.value)); savePs5Host(hostInput.value); }); header.appendChild(controls); } const count = document.querySelector('.pegasus-fw-count'); if (count) { const text = prefsActive() && view.key ? `${view.total.toLocaleString()} match` : ''; if (count.textContent !== text) count.textContent = text; } const served = view.pages.flat(); syncSendButton(); document.querySelectorAll('.package-grid .package-card').forEach((card, index) => { const pkg = served[index]; const id = card.querySelector('.package-card-id'); // Only badge a card whose title ID matches what was served at its slot. const text = pkg && id && id.textContent === (pkg.titleId || '') ? badgeText(pkg) : ''; let badge = card.querySelector('.pegasus-fw-badge'); if (!text) { if (badge) badge.remove(); return; } if (!badge) { badge = document.createElement('span'); badge.className = 'pegasus-fw-badge'; card.appendChild(badge); } if (badge.textContent !== text) badge.textContent = text; }); } function syncSendButton() { const pane = document.querySelector('.detail-pane'); const title = pane && pane.querySelector('.detail-title'); const shownId = title && title.querySelector('span'); const shownTitle = title && title.querySelector('h2'); const matches = selected && shownTitle && shownTitle.textContent === (selected.title || '') && (!shownId || shownId.textContent === (selected.titleId || '')); let button = pane && pane.querySelector('.pegasus-send'); if (!matches) { if (button) button.remove(); return; } if (!button) { button = document.createElement('button'); button.type = 'button'; button.className = 'pegasus-send primary-button'; button.textContent = 'Send to PS5'; button.title = 'Open this package in Pegasus DL on your PS5'; button.addEventListener('click', () => sendToPs5(selected)); title.after(button); } } function sendToPs5(pkg) { const origin = ps5Origin(loadPs5Host()); if (!origin) { const input = document.querySelector('.pegasus-fw-ps5'); if (input) { input.setAttribute('aria-invalid', ''); input.focus(); } return; } const request = { catalog: sourceUrls.get(view.slug) || `${location.origin}/catalogs/${view.slug}.json`, titleId: pkg.titleId || '', title: pkg.title || '' }; // A named window is reused, so later sends go to the same Pegasus DL tab. window.open(`${origin}/${OPEN_HASH}${encodeURIComponent(JSON.stringify(request))}`, 'pegasus-dl'); } // Accepts "192.168.0.12", "192.168.0.12:6970" or a hostname; returns an // http origin, or '' when the value is not a plain host. function ps5Origin(value) { const match = /^(?:https?:\/\/)?([a-z0-9.-]+)(?::(\d{1,5}))?\/?$/i.exec(String(value || '').trim()); return match ? `http://${match[1]}:${match[2] || DEFAULT_PORT}` : ''; } function loadPs5Host() { try { return localStorage.getItem(PS5_HOST_KEY) || ''; } catch (error) { return ''; } } function savePs5Host(value) { try { localStorage.setItem(PS5_HOST_KEY, value); } catch (error) { // Storage unavailable: the address lasts for this page only. } } // Records which served package a grid click selected. Capture phase, so it // runs before React re-renders the detail pane. document.addEventListener('click', (event) => { const card = event.target.closest && event.target.closest('.package-grid .package-card'); if (!card) return; const index = [...document.querySelectorAll('.package-grid .package-card')].indexOf(card); const pkg = view.pages.flat()[index]; const id = card.querySelector('.package-card-id'); selected = pkg && (!id || id.textContent === (pkg.titleId || '')) ? pkg : null; scheduleSync(); }, true); addStyle(` .catalog-header { flex-wrap: wrap; row-gap: 12px; } .catalog-header .pegasus-fw-controls { flex-basis: 100%; } .pegasus-fw-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .pegasus-fw-select { appearance: none; background: var(--surface-low); border: 1px solid var(--border); border-radius: 4px; color: var(--text); cursor: pointer; font: inherit; font-size: 13px; height: 36px; padding: 0 12px; } .pegasus-fw-select:hover, .pegasus-fw-select:focus-visible { border-color: var(--border-strong); } .pegasus-fw-count { color: var(--text-muted); font-size: 13px; white-space: nowrap; } .pegasus-fw-ps5 { background: var(--surface-low); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font: inherit; font-size: 13px; height: 36px; margin-left: auto; padding: 0 12px; width: 200px; } .pegasus-fw-ps5[aria-invalid] { border-color: var(--error); } .detail-pane .pegasus-send { margin: 12px 0; } .package-card .pegasus-fw-badge { align-self: flex-start; justify-self: start; width: fit-content; border: 1px solid var(--border-strong); border-radius: 4px; color: var(--text-muted); font-size: 11px; margin-top: 4px; padding: 0 6px; white-space: nowrap; } `); const start = () => { new MutationObserver(scheduleSync).observe(document.body, { childList: true, subtree: true }); scheduleSync(); }; if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true }); } if (location.hostname === 'pegasus-catalog.fly.dev') { // Runs at document-start so fetch is wrapped before the app's first request. initCatalogSite(); } else if (document.readyState === 'loading') { // Pegasus DL defines its globals in inline scripts at the end of the page. document.addEventListener('DOMContentLoaded', initPegasusDl, { once: true }); } else { initPegasusDl(); } })();