Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Each GitHub issue requests **one preset**. Game requests must identify a [GameDB

The [website](https://app.lizardbyte.dev/PresetDB/) shows games and apps with available presets. Each preset shows its launch method and host details as badges, its launch command, and a Sunshine application JSON export. The exported application name is the game or app name. Replace supported path placeholders with paths on your host. Review community commands before running them. Game cover images come from GameDB; app images are submitted as HTTPS URLs for the catalog. These image URLs are not used as Sunshine `image-path` values, which require local files.

Browsing shows 24 games and apps per page. Type at least three characters to search for any part of a name, then use the type and host OS filters to narrow the results. The website loads a small catalog manifest and only the chunks needed for the current page. Search runs in a web worker using a static substring index generated during the Pages build; it fetches only the relevant search files and the current page's summaries. Launch details load when you select an item or open a direct link. No search service is required.

The published JSON API contains `index.json`, `games/<IGDB ID>.json`, and `apps/<app slug>.json`. GameDB uses the same numeric game IDs as IGDB. An entry contains all its presets. Issue-backed presets receive the originating issue number as a stable string ID, such as "4". GameDB-imported Steam presets use "steam" and have no issue history. Records use schema version 2 with a `command` field or a `commands_by_os` map and an optional `working_directory`; the website builds Sunshine JSON from these generic fields. The migration workflow updates version 1 records on the database branch; site and approval reads also convert them during the rollout.

## Database growth
Expand Down
6 changes: 5 additions & 1 deletion docs/developerSetup.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ Build the static site from the local database layout with:
node src/build-site.js --database database --output site-build
```

The site builder writes `index.json`, `stats.json`, two SVG contribution charts, and game and app records. It uses the `gh-pages-template` directory as the Jekyll source. The Pages workflow packages that output for the shared LizardByte Jekyll workflow.
The site builder writes `index.json`, `stats.json`, two SVG contribution charts, game and app records, and a paged catalog under `catalog/`. It uses the `gh-pages-template` directory as the Jekyll source. The Pages workflow packages that output for the shared LizardByte Jekyll workflow.

The browser reads `catalog/manifest.json` instead of the complete `index.json` API. The manifest contains counts for each type/OS filter and the sizes of the generated chunks. Catalog files live under a content-derived revision so browser caches cannot mix ordinals from different builds. Summary chunks contain 96 items; browse chunks contain 240 sorted item ordinals for a particular filter. The worker returns only the selected page of 24 summaries and retains at most 16 fetched chunks plus the most recent search result.

Search requires three characters and preserves case-insensitive substring matching, including punctuation and Unicode. At build time, each consecutive three-character sequence maps to a sorted list of item ordinals and character positions. For a query, the worker loads enough sequences to cover every character and checks their relative positions, preventing matches assembled from unrelated parts of a name. Type and OS flags in the postings allow filtering and counting without loading other item summaries. Search and summary downloads use at most four concurrent requests; new requests cancel superseded work. Typing is debounced by 250 milliseconds. Existing `index.json`, `games/<id>.json`, and `apps/<slug>.json` API endpoints remain available.

Read the Docs pull request previews use `.readthedocs.yaml` and the shared `readthedocs_build.sh` script. Connect the repository to Read the Docs, enable pull request builds, and set these project environment variables:

Expand Down
131 changes: 89 additions & 42 deletions gh-pages-template/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,6 @@ const METHOD_NAMES = {
gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator'
};

function filterItems(index, query, kind, os) {
const needle = query.trim().toLowerCase();
return [
...(index.games || []).map(item => ({ ...item, kind: 'game' })),
...(index.apps || []).map(item => ({ ...item, kind: 'app' }))
].filter(item =>
(kind === 'all' || item.kind === kind) &&
(os === 'all' || item.operating_systems.includes(os)) &&
item.name.toLowerCase().includes(needle)
).sort((a, b) => a.name.localeCompare(b.name));
}

function commandForOs(preset, os = 'Windows') {
const command = preset.commands_by_os?.[os] || preset.command;
if (!command) throw new Error('Choose an available host OS');
Expand Down Expand Up @@ -203,13 +191,31 @@ function boot() {
const list = document.getElementById('preset-list');
const status = document.getElementById('preset-status');
const detail = document.getElementById('preset-detail');
let index;
const previous = document.getElementById('preset-previous');
const next = document.getElementById('preset-next');
const pageStatus = document.getElementById('preset-page');
let page = 0;
let requestId = 0;
let debounce;
let detailController;
let detailId = 0;
let worker;

function renderList() {
if (!index) return;
const items = filterItems(index, search.value, kind.value, os.value);
function renderList(result) {
const { items, total, page_count: pageCount } = result;
page = result.page;
list.replaceChildren();
status.textContent = items.length ? `${items.length} games and apps found` : 'No matching presets yet.';
list.setAttribute('aria-busy', 'false');
previous.disabled = page === 0;
next.disabled = page + 1 >= pageCount;
pageStatus.textContent = pageCount ? `Page ${(page + 1).toLocaleString()} of ${pageCount.toLocaleString()}` : '';
if (result.min_query_length) {
status.textContent = `Enter at least ${result.min_query_length} characters to search, or clear the search to browse.`;
} else {
status.textContent = total
? `${total.toLocaleString()} games and apps found · Showing ${(result.start + 1).toLocaleString()}–${(result.start + items.length).toLocaleString()}`
: 'No matching presets yet.';
}
for (const item of items) {
const column = element('div', 'col');
const button = element('button', 'card h-100 w-100 text-start border-0 shadow-sm rounded-0 p-4');
Expand All @@ -234,13 +240,18 @@ function boot() {
}

async function showRecord(item) {
detailController?.abort();
detailController = new AbortController();
const controller = detailController;
const id = ++detailId;
detail.hidden = false;
detail.replaceChildren(element('p', '', 'Loading launch options…'));
try {
const folder = item.kind === 'game' ? 'games' : 'apps';
const response = await fetch(`${base}/${folder}/${encodeURIComponent(item.id)}.json`);
const response = await fetch(`${base}/${folder}/${encodeURIComponent(item.id)}.json`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const record = await response.json();
if (id !== detailId) return;
const heading = element('h2', 'fw-bold', record.name);
detail.replaceChildren(heading, safeLink(record.source_url, item.kind === 'game' ? 'View on IGDB ↗' : 'Official app site ↗'));
const image = safeImage(record.image_url);
Expand All @@ -251,37 +262,73 @@ function boot() {
detail.append(presets);
detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (error) {
if (controller.signal.aborted || id !== detailId) return;
detail.replaceChildren(element('p', 'text-danger', `Could not load presets: ${error.message}`));
}
}

async function load() {
try {
const response = await fetch(`${base}/index.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
index = await response.json();
renderList();
const params = new URLSearchParams(globalThis.location.search);
const requestedKind = params.get('kind');
const requestedId = params.get('id');
if (requestedId && ['game', 'app'].includes(requestedKind)) {
const item = filterItems(index, '', requestedKind, 'all').find(entry => String(entry.id) === requestedId);
if (item) showRecord(item);
}
} catch (error) {
status.textContent = `Could not load the preset index: ${error.message}`;
function loadRequestedRecord() {
const params = new URLSearchParams(globalThis.location.search);
const requestedKind = params.get('kind');
const requestedId = params.get('id');
if (requestedId && ['game', 'app'].includes(requestedKind)) {
// Record loading reports failures in the detail panel.
void showRecord({ kind: requestedKind, id: requestedId });
} else {
detailController?.abort();
detailId++;
detail.hidden = true;
}
}

for (const control of [search, kind, os]) control.addEventListener('input', renderList);
globalThis.addEventListener('popstate', () => {
const params = new URLSearchParams(globalThis.location.search);
const item = index && filterItems(index, '', params.get('kind'), 'all').find(entry => String(entry.id) === params.get('id'));
if (item) showRecord(item);
else detail.hidden = true;
});
load();
function loading() {
previous.disabled = true;
next.disabled = true;
pageStatus.textContent = '';
status.textContent = 'Loading presets…';
list.setAttribute('aria-busy', 'true');
}

function requestPage() {
clearTimeout(debounce);
loading();
worker.postMessage({ id: ++requestId, base, query: search.value, kind: kind.value, os: os.value, page });
}

function catalogError(message) {
list.setAttribute('aria-busy', 'false');
status.textContent = `Could not load the preset catalog: ${message}. Reload the page to try again.`;
}

try {
worker = new Worker(`${base}/assets/js/search-worker.js`);
worker.addEventListener('message', event => {
if (event.data.id !== requestId) return;
if (event.data.error) catalogError(event.data.error);
else renderList(event.data.result);
});
worker.addEventListener('error', () => catalogError('Search worker unavailable'));
search.addEventListener('input', () => {
page = 0;
requestId++;
loading();
clearTimeout(debounce);
debounce = setTimeout(requestPage, 250);
});
for (const control of [kind, os]) control.addEventListener('input', () => {
page = 0;
requestPage();
});
previous.addEventListener('click', () => { page--; requestPage(); });
next.addEventListener('click', () => { page++; requestPage(); });
requestPage();
} catch (error) {
catalogError(error.message);
}

globalThis.addEventListener('popstate', loadRequestedRecord);
loadRequestedRecord();
}

if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', boot);
if (typeof module !== 'undefined') module.exports = { filterItems, commandForOs, sunshineSnippet, renderPresetCard, browseMethodBadges, normalizeBasePath };
if (typeof module !== 'undefined') module.exports = { boot, commandForOs, sunshineSnippet, renderPresetCard, browseMethodBadges, normalizeBasePath };
192 changes: 192 additions & 0 deletions gh-pages-template/assets/js/catalog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
'use strict';

const PAGE_SIZE = 24;
const CATALOG_CHUNK_SIZE = 96;
const BROWSE_CHUNK_SIZE = 240;
const MIN_QUERY_LENGTH = 3;
const OS_FLAGS = { Windows: 2, Linux: 4, macOS: 8 };

function normalizedName(value) {
return String(value).trim().toLowerCase();
}

function gramKey(gram) {
return Array.from(gram, character => character.codePointAt(0).toString(16).padStart(6, '0')).join('');
}

function itemFlags(item) {
return (item.kind === 'game' ? 1 : 0) |
item.operating_systems.reduce((flags, os) => flags | (OS_FLAGS[os] || 0), 0);
}

function matchesFlags(code, kind, os) {
const flags = code % 16;
return (kind === 'all' || Boolean(flags & 1) === (kind === 'game')) &&
(os === 'all' || Boolean(flags & OS_FLAGS[os]));
}

// Non-overlapping grams plus the final gram cover every query character. The
// stored positions make their intersection an exact substring search.
function queryGrams(query) {
const characters = Array.from(query);
const grams = [];
for (let offset = 0; offset <= characters.length - 3; offset += 3) {
grams.push({ key: gramKey(characters.slice(offset, offset + 3).join('')), offset });
}
const lastOffset = characters.length - 3;
if (grams.length && grams.at(-1).offset !== lastOffset) {
grams.push({ key: gramKey(characters.slice(lastOffset).join('')), offset: lastOffset });
}
return grams;
}

function containsPosition(postings, code, position) {
let low = 0;
let high = postings.length / 2;
while (low < high) {
const middle = Math.floor((low + high) / 2);
const item = postings[middle * 2];
const offset = postings[middle * 2 + 1];
if (item < code || (item === code && offset < position)) low = middle + 1;
else high = middle;
}
return postings[low * 2] === code && postings[low * 2 + 1] === position;
}

function searchPositions(grams, postings, kind, os) {
const ordered = [...grams].sort((a, b) => postings.get(a.key).length - postings.get(b.key).length);
const anchor = ordered[0];
const candidates = postings.get(anchor.key);
const results = [];
for (let i = 0; i < candidates.length; i += 2) {
const code = candidates[i];
const ordinal = Math.floor(code / 16);
const start = candidates[i + 1] - anchor.offset;
if (start < 0 || results.at(-1) === ordinal || !matchesFlags(code, kind, os)) continue;
if (ordered.every(gram => containsPosition(postings.get(gram.key), code, start + gram.offset))) {
results.push(ordinal);
}
}
return results;
}

class CatalogClient {
constructor(base, fetcher = globalThis.fetch.bind(globalThis)) {
this.base = base;
this.fetcher = fetcher;
this.cache = new Map();
}

async manifest(signal) {
if (this.metadata) return this.metadata;
const response = await this.fetcher(`${this.base}/catalog/manifest.json`, { signal, cache: 'no-cache' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const manifest = await response.json();
if (manifest.schema_version !== 1 || !/^[a-f0-9]{16}$/.test(manifest.revision)) {
throw new Error('Unsupported catalog version');
}
signal?.throwIfAborted();
this.metadata = manifest;
return manifest;
}

async json(file, signal, missingIsEmpty = false) {
if (this.cache.has(file)) {
const value = this.cache.get(file);
this.cache.delete(file);
this.cache.set(file, value);
return value;
}
const response = await this.fetcher(`${this.base}/catalog/${this.metadata.revision}/${file}`, { signal });
let value;
if (missingIsEmpty && response.status === 404) value = [];
else {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
value = await response.json();
}
signal?.throwIfAborted();
this.cache.set(file, value);
// Bound retained data even when someone searches or browses many pages.
if (this.cache.size > 16) this.cache.delete(this.cache.keys().next().value);
return value;
}

async search(query, kind, os, signal) {
const grams = queryGrams(query);
const keys = [...new Set(grams.map(gram => gram.key))];
const postings = new Map();
let next = 0;
let empty = false;
const loadNext = async () => {
if (next >= keys.length || empty) return;
signal?.throwIfAborted();
const key = keys[next++];
const values = await this.json(`search/${key}.json`, signal, true);
postings.set(key, values);
if (!values.length) empty = true;
return loadNext();
};
await Promise.all(Array.from({ length: Math.min(4, keys.length) }, loadNext));
return empty ? [] : searchPositions(grams, postings, kind, os);
}

async items(ordinals, signal) {
const size = this.metadata.catalog_chunk_size;
const chunks = [...new Set(ordinals.map(ordinal => Math.floor(ordinal / size)))];
const loaded = new Map();
let next = 0;
const loadNext = async () => {
if (next >= chunks.length) return;
signal?.throwIfAborted();
const chunk = chunks[next++];
loaded.set(chunk, await this.json(`items/${chunk}.json`, signal));
return loadNext();
};
await Promise.all(Array.from({ length: Math.min(4, chunks.length) }, loadNext));
return ordinals.map(ordinal => loaded.get(Math.floor(ordinal / size))[ordinal % size]);
}

async page({ query = '', kind = 'all', os = 'all', page = 0 } = {}, signal) {
const manifest = await this.manifest(signal);
if (!['all', 'game', 'app'].includes(kind) || !['all', ...Object.keys(OS_FLAGS)].includes(os)) {
throw new Error('Invalid catalog filter');
}
const needle = normalizedName(query);
if (needle && Array.from(needle).length < manifest.min_query_length) {
return { items: [], total: 0, page: 0, page_count: 0, min_query_length: manifest.min_query_length };
}
let matches = null;
if (needle) {
const key = JSON.stringify([needle, kind, os]);
if (this.lastSearch?.key === key) matches = this.lastSearch.matches;
else {
matches = await this.search(needle, kind, os, signal);
signal?.throwIfAborted();
this.lastSearch = { key, matches };
}
}
const total = matches ? matches.length : manifest.counts[kind][os];
const pageCount = Math.ceil(total / manifest.page_size);
const selectedPage = Math.min(Math.max(Number.isSafeInteger(page) ? page : 0, 0), Math.max(pageCount - 1, 0));
const start = selectedPage * manifest.page_size;
const length = Math.min(manifest.page_size, total - start);
let ordinals;
if (matches) ordinals = matches.slice(start, start + length);
else if (kind === 'all' && os === 'all') {
ordinals = Array.from({ length }, (_, i) => start + i);
} else if (length) {
const chunk = Math.floor(start / manifest.browse_chunk_size);
const values = await this.json(`browse/${kind}/${os}/${chunk}.json`, signal);
const offset = start % manifest.browse_chunk_size;
ordinals = values.slice(offset, offset + length);
} else ordinals = [];
return { items: await this.items(ordinals, signal), total, start, page: selectedPage, page_count: pageCount };
}
}

const catalogApi = {
PAGE_SIZE, CATALOG_CHUNK_SIZE, BROWSE_CHUNK_SIZE, MIN_QUERY_LENGTH,
normalizedName, gramKey, itemFlags, CatalogClient
};
if (typeof module !== 'undefined') module.exports = catalogApi;
else globalThis.PresetCatalog = catalogApi;
Loading
Loading