From b20d3954c6a8de9e46a6da307d48582db168fb61 Mon Sep 17 00:00:00 2001 From: ovosimpatico Date: Thu, 14 Nov 2024 10:01:10 -0300 Subject: [PATCH] Initial implementation of the web emulator --- .env | 4 +- docker-compose.yml | 1 + lib/emulatorConfig.js | 69 ++++++++++++ lib/nonGameTerms.json | 56 ++++++++++ package-lock.json | 100 ++++++++++++++++++ package.json | 3 +- server.js | 63 ++++++++++- views/pages/about.ejs | 26 +++++ views/pages/emulator.ejs | 223 +++++++++++++++++++++++++++++++++++++++ views/pages/results.ejs | 26 ++++- views/partials/head.ejs | 1 + 11 files changed, 568 insertions(+), 4 deletions(-) create mode 100644 lib/emulatorConfig.js create mode 100644 lib/nonGameTerms.json create mode 100644 views/pages/emulator.ejs diff --git a/.env b/.env index 6c17a8f..8c10f00 100644 --- a/.env +++ b/.env @@ -10,4 +10,6 @@ MAX_JOB_QUEUE=1000 # Changes the maximum number of pages that can be fetched for parsing. Has a massive impact on memory usage. Setting to 12 results in about 1.1GiB memory usage MAX_FETCH_JOBS=1000 # Changes the name of your instance -INSTANCE_NAME=Myrient \ No newline at end of file +INSTANCE_NAME=Myrient +# Enable the built-in emulator +EMULATOR_ENABLED=true diff --git a/docker-compose.yml b/docker-compose.yml index df8f72f..aac0251 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - MAX_JOB_QUEUE=1000 - MAX_FETCH_JOBS=1000 - INSTANCE_NAME=Myrient + - EMULATOR_ENABLED=true volumes: - ./data:/usr/src/app/data restart: unless-stopped \ No newline at end of file diff --git a/lib/emulatorConfig.js b/lib/emulatorConfig.js new file mode 100644 index 0000000..9588888 --- /dev/null +++ b/lib/emulatorConfig.js @@ -0,0 +1,69 @@ +// See https://emulatorjs.org/docs/systems for available cores +const coreMap = { + // Nintendo Systems + 'Nintendo Entertainment System': 'fceumm', + 'Super Nintendo Entertainment System': 'snes9x', + 'Nintendo 64': 'mupen64plus_next', + 'Nintendo DS': 'desmume2015', + 'Nintendo Game Boy': 'gambatte', + 'Nintendo Game Boy Color': 'gambatte', + 'Nintendo Game Boy Advance': 'mgba', + + // Sega Systems + 'Sega Master System': 'smsplus', + 'Sega Game Gear': 'genesis_plus_gx', // TODO: fix rom loading + 'Sega Mega Drive': 'genesis_plus_gx', + 'Sega CD': 'genesis_plus_gx', // TODO: add bios + 'Sega 32X': 'picodrive', // Known issue: https://github.com/EmulatorJS/EmulatorJS/issues/579 + 'Sega Saturn': 'yabause', + + // Atari Systems + 'Atari 2600': 'stella2014', + 'Atari 5200': 'a5200', + 'Atari 7800': 'prosystem', + 'Atari Jaguar': 'virtualjaguar', + 'Atari Lynx': 'handy', + + // Commodore Systems + 'Commodore 64': 'vice_x64sc', + 'Commodore 128': 'vice_x128', // Untested, Myrient has no ROMs for it + 'Commodore Amiga': 'puae', // TODO: fix rom loading + 'Commodore PET': 'vice_xpet', // Untested, Myrient has no ROMs for it + 'Commodore Plus-4': 'vice_xplus4', // TODO: fix rom loading + 'Commodore VIC-20': 'vice_xvic', // TODO: fix rom loading + + // Sony Systems + 'Sony PlayStation 1': 'pcsx_rearmed', // TODO: fix rom loading + + // Other Systems + 'Arcade': 'fbneo', // TODO: fix rom loading + 'ColecoVision': 'gearcoleco', // TODO: add bios + 'Panasonic 3DO': 'opera', // TODO: fix rom loading +}; + +const COMPATIBLE_SYSTEMS = Object.keys(coreMap); + +export function isEmulatorCompatible(category) { + if (process.env.EMULATOR_ENABLED !== 'true') { + return false; + } + return COMPATIBLE_SYSTEMS.includes(category); +} + +export function getEmulatorConfig(category) { + const core = coreMap[category] || 'unknown'; + + // Add system-specific settings + const config = { + core, + system: category, + options: {} + }; + + return config; +} + +export function isNonGameContent(filename, nonGameTerms) { + const pattern = new RegExp(nonGameTerms.terms.join('|'), 'i'); + return pattern.test(filename); +} \ No newline at end of file diff --git a/lib/nonGameTerms.json b/lib/nonGameTerms.json new file mode 100644 index 0000000..1aec0c6 --- /dev/null +++ b/lib/nonGameTerms.json @@ -0,0 +1,56 @@ +{ + "terms": [ + "7z", + "addon", + "artwork", + "audio", + "beta", + "box", + "boxart", + "cheat", + "config", + "cfg", + "csv", + "debug", + "dlc", + "document", + "driver", + "editor", + "emulator", + "expansion", + "firmware", + "guide", + "hack", + "html", + "ini", + "installer", + "intro", + "json", + "manual", + "mod", + "movie", + "music", + "ost", + "overlay", + "patch", + "plugin", + "preview", + "readme", + "rom", + "screenshot", + "sample", + "save", + "savestate", + "sdk", + "setup", + "soundtrack", + "terms", + "tool", + "trainer", + "txt", + "update", + "utility", + "video", + "wallpaper" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 98e09ca..cac3293 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "file-older-than": "^1.0.0", "innertext": "^1.0.3", "jsdom": "^25.0.1", + "jszip": "^3.10.1", "minisearch": "^7.1.0", "node-cron": "^3.0.3", "node-fetch": "^3.3.2", @@ -597,6 +598,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -1272,6 +1279,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1302,6 +1315,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -1360,6 +1379,27 @@ } } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1569,6 +1609,12 @@ "node": ">= 0.8" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.0.tgz", @@ -1605,6 +1651,12 @@ "@napi-rs/nice": "^1.0.1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1666,6 +1718,27 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -1791,6 +1864,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1824,6 +1903,21 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1915,6 +2009,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 3fa9957..de2c409 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "node-html-parser": "^6.1.13", "piscina": "^4.7.0", "sanitize": "^2.1.2", - "figlet": "^1.7.0" + "figlet": "^1.7.0", + "jszip": "^3.10.1" }, "type": "module" } diff --git a/server.js b/server.js index 2657975..70ff32a 100644 --- a/server.js +++ b/server.js @@ -10,13 +10,17 @@ import sanitize from "sanitize"; import debugPrint from "./lib/debugprint.js"; import compression from "compression"; import { generateAsciiArt } from './lib/asciiart.js'; +import { getEmulatorConfig, isEmulatorCompatible, isNonGameContent } from './lib/emulatorConfig.js'; +import fetch from 'node-fetch'; let fileListPath = "./data/filelist.json"; let queryCountFile = "./data/queries.txt"; let categoryListPath = "./lib/categories.json" let searchAlikesPath = './lib/searchalikes.json' +let nonGameTermsPath = './lib/nonGameTerms.json' let categoryList = await FileHandler.parseJsonFile(categoryListPath); global.searchAlikes = await FileHandler.parseJsonFile(searchAlikesPath) +let nonGameTerms = await FileHandler.parseJsonFile(nonGameTermsPath); let crawlTime = 0; let queryCount = 0; let fileCount = 0; @@ -101,7 +105,8 @@ let defaultOptions = { queryCount: queryCount, fileCount: fileCount, termCount: search.miniSearch.termCount, - generateAsciiArt: generateAsciiArt + generateAsciiArt: generateAsciiArt, + isEmulatorCompatible: isEmulatorCompatible }; function updateDefaults(){ @@ -216,6 +221,62 @@ app.get("/about", function (req, res) { res.render(indexPage, buildOptions(page)); }); +app.get("/play/:id", async function (req, res) { + // Block access if emulator is disabled + if (process.env.EMULATOR_ENABLED !== 'true') { + res.redirect('/'); + return; + } + + let fileId = parseInt(req.params.id); + let romFile = search.findIndex(fileId); + + if (!romFile) { + res.redirect('/'); + return; + } + + let options = { + romFile: romFile, + emulatorConfig: getEmulatorConfig(romFile.category), + isNonGame: isNonGameContent(romFile.filename, nonGameTerms) + }; + + let page = "emulator"; + options = buildOptions(page, options); + res.render(indexPage, options); +}); + +app.get("/proxy-rom/:id", async function (req, res) { + // Block access if emulator is disabled + if (process.env.EMULATOR_ENABLED !== 'true') { + res.status(403).send('Emulator feature is disabled'); + return; + } + + let fileId = parseInt(req.params.id); + let romFile = search.findIndex(fileId); + + if (!romFile) { + res.status(404).send('ROM not found'); + return; + } + + try { + const response = await fetch(romFile.path); + const contentLength = response.headers.get('content-length'); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Length', contentLength); + res.setHeader('Content-Disposition', `attachment; filename="${romFile.filename}"`); + + response.body.pipe(res); + } catch (error) { + console.error('Error proxying ROM:', error); + res.status(500).send('Error fetching ROM'); + } +}); + server.listen(process.env.PORT, process.env.BIND_ADDRESS); server.on("listening", function () { console.log( diff --git a/views/pages/about.ejs b/views/pages/about.ejs index 9bcace0..8c2e41e 100644 --- a/views/pages/about.ejs +++ b/views/pages/about.ejs @@ -18,6 +18,32 @@ Donate to Myrient +
+
Built-in Emulator
+ <% if (process.env.EMULATOR_ENABLED === 'true') { %> +

This website includes a built-in emulator powered by EmulatorJS that brings retro gaming directly to your browser.

+

Compatible games will feature a play button on their search result page.

+ +

For the best gaming experience, use a Chromium-based browser with hardware acceleration turned on.

+ +

+ + + Games are loaded directly from Myrient's public archive. Save states are stored locally in the browser. + +
+ + + ROM hacks, soundtracks, and other non-game content are not supported by the emulator and may fail to load. + +

+ + <% } else { %> +

Web Emulator functionality was disabled by the administrator.

+

Contact the administrator or spin up your own instance of <%= process.env.INSTANCE_NAME || 'Myrient' %> Search.

+ <% } %> +
+

Search engine created by Alexankitty

View project on GitHub

diff --git a/views/pages/emulator.ejs b/views/pages/emulator.ejs new file mode 100644 index 0000000..b838ca8 --- /dev/null +++ b/views/pages/emulator.ejs @@ -0,0 +1,223 @@ +
+ +
+
+

<%= romFile.filename.replace(/\.[^/.]+$/, '') %>

+

<%= romFile.category %>

+ + <% if (isNonGame) { %> + + <% } %> +
+
+ + +
+
+
+
+ +
+
+
+ Loading ROM... +
+
+
+
+
+
+ + +
+
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/views/pages/results.ejs b/views/pages/results.ejs index f79239a..56cfac0 100644 --- a/views/pages/results.ejs +++ b/views/pages/results.ejs @@ -45,6 +45,9 @@ Size Date Search Score + <% if (process.env.EMULATOR_ENABLED === 'true') { %> + Play + <% } %> <% for (let x = entryStart; x < entryEnd; x++) { %> @@ -72,6 +75,15 @@ <%= results.items[x].score.toFixed(2) %> + <% if (process.env.EMULATOR_ENABLED === 'true') { %> + + <% if (isEmulatorCompatible(results.items[x].category)) { %> + Play + <% } else { %> + + <% } %> + + <% } %> <% } %> @@ -128,7 +140,19 @@