├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── bin └── mscraper.js ├── package-lock.json ├── package.json ├── pic.jpg ├── scripts ├── entrypoint.sh └── minui2nextui.sh ├── src ├── art.ts ├── cli.ts ├── file.ts ├── format │ ├── anbernic.ts │ ├── format.ts │ ├── minui.ts │ ├── muos.ts │ └── nextui.ts ├── image.ts ├── index.ts ├── libretro.ts ├── machines.ts ├── matcher.ts ├── ollama.ts ├── options.ts └── stats.ts ├── test ├── DOS │ ├── Dark Forces.zip │ └── Doom.zip ├── GBC │ ├── Pokemon - Version Argent (France) (SGB Enhanced).zip │ └── Wario Land 3 (World) (En,Ja).zip ├── Game Boy (GB) │ ├── Best │ │ └── Tetris.gb │ ├── Addams Family, The (USA).gb │ └── Hacks │ │ ├── Metroid II - Return of Samus (Map).zip │ │ ├── Super Mario Land 2 - 6 Golden Coins DX.zip │ │ ├── Super Mario Land DX.zip │ │ └── Tetris - Rosy Retrospection.zip ├── PS │ ├── Colony Wars (France) │ │ └── Colony Wars (France).m3u │ ├── Final Fantasy VII (Europe) (Disc 1).chd │ ├── Final Fantasy VII (Europe).m3u │ ├── Final Fantasy VII (France) │ │ ├── Final Fantasy VII (France) (Disc 1).chd │ │ └── Final Fantasy VII (France).m3u │ └── Moto Racer 2 (Europe) (En,Fr,De,Es,It,Sv).chd ├── SNES │ └── Super Mario World.zip ├── autorun.inf └── ignore │ └── ignoreme.txt └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | max_line_length = 120 12 | 13 | [*.md] 14 | max_line_length = off 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: ['main'] 5 | pull_request: 6 | branches: ['main'] 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | platform: [ubuntu-latest, macos-latest, windows-latest] 13 | node-version: ['20'] 14 | 15 | name: ${{ matrix.platform }} / Node.js v${{ matrix.node-version }} 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - run: git config --global core.autocrlf false # Preserve line endings on Windows 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: | 24 | npm ci 25 | npm test 26 | 27 | build_all: 28 | if: always() 29 | runs-on: ubuntu-latest 30 | needs: build 31 | steps: 32 | - name: Check build matrix status 33 | if: ${{ needs.build.result != 'success' }} 34 | run: exit 1 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | workflow_dispatch: 4 | repository_dispatch: 5 | types: [release] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: github.ref == 'refs/heads/main' 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | persist-credentials: false 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | - run: | 19 | npm ci 20 | npm run build 21 | env: 22 | CI: true 23 | - run: npx semantic-release 24 | if: success() 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | # Need owner/admin account to bypass branch protection 29 | GIT_COMMITTER_NAME: sinedied 30 | GIT_COMMITTER_EMAIL: noda@free.fr 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | .DS_Store 5 | Thumbs.db 6 | /test/**/*.png 7 | **/theme/override/ 8 | TODO -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.5.1](https://github.com/sinedied/mini-scraper/compare/1.5.0...1.5.1) (2025-04-05) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * check if multi-disk m3u file is already in machine dir ([c3449a0](https://github.com/sinedied/mini-scraper/commit/c3449a00887db0fef951499645870849fd19b5c4)) 7 | * encode URIs in `loadImage` ([d2b1267](https://github.com/sinedied/mini-scraper/commit/d2b12679d4815e2a29c74e84baf4b336537c54dd)) 8 | * move NES after DS, since it has the shorter alias ([9827f3e](https://github.com/sinedied/mini-scraper/commit/9827f3e33e2f776cffb03d70529f07791e530db9)) 9 | 10 | # [1.5.0](https://github.com/sinedied/mini-scraper/compare/1.4.1...1.5.0) (2025-03-29) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * improve subtitle handling in matching logic ([#8](https://github.com/sinedied/mini-scraper/issues/8)) ([8f72ea4](https://github.com/sinedied/mini-scraper/commit/8f72ea4efeb55e95fb933ba84446de09f793af9d)) 16 | 17 | 18 | ### Features 19 | 20 | * add Docker support ([#6](https://github.com/sinedied/mini-scraper/issues/6)) ([e320e85](https://github.com/sinedied/mini-scraper/commit/e320e85771e9105c332e5a8226e62cef13023edc)) 21 | * add fuzzy matching ([#9](https://github.com/sinedied/mini-scraper/issues/9)) ([#10](https://github.com/sinedied/mini-scraper/issues/10)) ([5d355f4](https://github.com/sinedied/mini-scraper/commit/5d355f427e7cad4e25cbc2b00b7cc4f2fb6e20ba)) 22 | * disable separate artwork scraping for muOS ([0d76062](https://github.com/sinedied/mini-scraper/commit/0d76062ae716fae07f2a579280d1d434ad707a6d)) 23 | * generate/update theme overrides for muOS ([7a178bb](https://github.com/sinedied/mini-scraper/commit/7a178bb7d67f2cb3f484c827965d29f7ac9fc23b)) 24 | * ignore "XX)" ordering before the rom name ([#7](https://github.com/sinedied/mini-scraper/issues/7)) ([a498452](https://github.com/sinedied/mini-scraper/commit/a498452ac52d2d4d441a50be284d1a4d0cc0882f)) 25 | * improve ai matching ([0ff04d3](https://github.com/sinedied/mini-scraper/commit/0ff04d3f5b8c573a7e97c3108416dbc559377fca)) 26 | 27 | ## [1.4.1](https://github.com/sinedied/mini-scraper/compare/1.4.0...1.4.1) (2025-03-16) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * remove rom extension from nextui images ([a54a675](https://github.com/sinedied/mini-scraper/commit/a54a6758ec9441c0bf3ff312234052d09736a7cc)) 33 | 34 | # [1.4.0](https://github.com/sinedied/mini-scraper/compare/1.3.0...1.4.0) (2025-03-11) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * muOS volume root detection ([b552919](https://github.com/sinedied/mini-scraper/commit/b552919b4a42c5fedf89a711c34f0f7097e0b5f6)) 40 | * package publish ([6e7ca08](https://github.com/sinedied/mini-scraper/commit/6e7ca086d9522719c3822d32b760ceb16f2187df)) 41 | 42 | 43 | ### Features 44 | 45 | * add --output option, without muOS support ([7ac002f](https://github.com/sinedied/mini-scraper/commit/7ac002f69d206aa46561b8279478eac7dbf5d9b1)) 46 | * add Naomi and Atomiswave machines ([a50e152](https://github.com/sinedied/mini-scraper/commit/a50e15236d958312aaa82d32107ecc0f92cdf4c1)) 47 | * add support for Anbernic stock OS output format ([2aae6ea](https://github.com/sinedied/mini-scraper/commit/2aae6ea945a9379271c2b801ba70056718b6a649)) 48 | * add support for NextUI output format (closes [#1](https://github.com/sinedied/mini-scraper/issues/1)) ([8c96391](https://github.com/sinedied/mini-scraper/commit/8c963912690871f33fc6275d9a8241832f4b431d)) 49 | * rename project to mini-scraper (due to name conflict) ([a892089](https://github.com/sinedied/mini-scraper/commit/a89208976633767c286cb5c0ea6fcd11050bd0d2)) 50 | * show elapsed time ([287ade2](https://github.com/sinedied/mini-scraper/commit/287ade27379dd95e04b36b8258bd62ea5d971862)) 51 | 52 | 53 | ### Reverts 54 | 55 | * version ([5864911](https://github.com/sinedied/mini-scraper/commit/586491193711dd05c8dd6f7dd4679b3fee54973f)) 56 | 57 | # [1.4.0](https://github.com/sinedied/mini-scraper/compare/1.3.0...1.4.0) (2025-03-11) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * muOS volume root detection ([b552919](https://github.com/sinedied/mini-scraper/commit/b552919b4a42c5fedf89a711c34f0f7097e0b5f6)) 63 | 64 | 65 | ### Features 66 | 67 | * add --output option, without muOS support ([7ac002f](https://github.com/sinedied/mini-scraper/commit/7ac002f69d206aa46561b8279478eac7dbf5d9b1)) 68 | * add Naomi and Atomiswave machines ([a50e152](https://github.com/sinedied/mini-scraper/commit/a50e15236d958312aaa82d32107ecc0f92cdf4c1)) 69 | * add support for Anbernic stock OS output format ([2aae6ea](https://github.com/sinedied/mini-scraper/commit/2aae6ea945a9379271c2b801ba70056718b6a649)) 70 | * add support for NextUI output format (closes [#1](https://github.com/sinedied/mini-scraper/issues/1)) ([8c96391](https://github.com/sinedied/mini-scraper/commit/8c963912690871f33fc6275d9a8241832f4b431d)) 71 | * rename project to mini-scraper (due to name conflict) ([a892089](https://github.com/sinedied/mini-scraper/commit/a89208976633767c286cb5c0ea6fcd11050bd0d2)) 72 | * show elapsed time ([287ade2](https://github.com/sinedied/mini-scraper/commit/287ade27379dd95e04b36b8258bd62ea5d971862)) 73 | 74 | # [1.4.0](https://github.com/sinedied/mini-scraper/compare/1.3.0...1.4.0) (2025-03-11) 75 | 76 | 77 | ### Bug Fixes 78 | 79 | * muOS volume root detection ([b552919](https://github.com/sinedied/mini-scraper/commit/b552919b4a42c5fedf89a711c34f0f7097e0b5f6)) 80 | 81 | 82 | ### Features 83 | 84 | * add --output option, without muOS support ([7ac002f](https://github.com/sinedied/mini-scraper/commit/7ac002f69d206aa46561b8279478eac7dbf5d9b1)) 85 | * add Naomi and Atomiswave machines ([a50e152](https://github.com/sinedied/mini-scraper/commit/a50e15236d958312aaa82d32107ecc0f92cdf4c1)) 86 | * add support for Anbernic stock OS output format ([2aae6ea](https://github.com/sinedied/mini-scraper/commit/2aae6ea945a9379271c2b801ba70056718b6a649)) 87 | * add support for NextUI output format (closes [#1](https://github.com/sinedied/mini-scraper/issues/1)) ([8c96391](https://github.com/sinedied/mini-scraper/commit/8c963912690871f33fc6275d9a8241832f4b431d)) 88 | * rename project to mini-scraper (due to name conflict) ([a892089](https://github.com/sinedied/mini-scraper/commit/a89208976633767c286cb5c0ea6fcd11050bd0d2)) 89 | * show elapsed time ([287ade2](https://github.com/sinedied/mini-scraper/commit/287ade27379dd95e04b36b8258bd62ea5d971862)) 90 | 91 | # [1.4.0](https://github.com/sinedied/mini-scraper/compare/1.3.0...1.4.0) (2025-03-11) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * muOS volume root detection ([b552919](https://github.com/sinedied/mini-scraper/commit/b552919b4a42c5fedf89a711c34f0f7097e0b5f6)) 97 | 98 | 99 | ### Features 100 | 101 | * add --output option, without muOS support ([7ac002f](https://github.com/sinedied/mini-scraper/commit/7ac002f69d206aa46561b8279478eac7dbf5d9b1)) 102 | * add Naomi and Atomiswave machines ([a50e152](https://github.com/sinedied/mini-scraper/commit/a50e15236d958312aaa82d32107ecc0f92cdf4c1)) 103 | * add support for Anbernic stock OS output format ([2aae6ea](https://github.com/sinedied/mini-scraper/commit/2aae6ea945a9379271c2b801ba70056718b6a649)) 104 | * add support for NextUI output format (closes [#1](https://github.com/sinedied/mini-scraper/issues/1)) ([8c96391](https://github.com/sinedied/mini-scraper/commit/8c963912690871f33fc6275d9a8241832f4b431d)) 105 | * rename project to mini-scraper (due to name conflict) ([a892089](https://github.com/sinedied/mini-scraper/commit/a89208976633767c286cb5c0ea6fcd11050bd0d2)) 106 | * show elapsed time ([287ade2](https://github.com/sinedied/mini-scraper/commit/287ade27379dd95e04b36b8258bd62ea5d971862)) 107 | 108 | # [1.4.0](https://github.com/sinedied/multi-scraper/compare/1.3.0...1.4.0) (2025-03-11) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * muOS volume root detection ([b552919](https://github.com/sinedied/multi-scraper/commit/b552919b4a42c5fedf89a711c34f0f7097e0b5f6)) 114 | 115 | 116 | ### Features 117 | 118 | * add --output option, without muOS support ([7ac002f](https://github.com/sinedied/multi-scraper/commit/7ac002f69d206aa46561b8279478eac7dbf5d9b1)) 119 | * add Naomi and Atomiswave machines ([a50e152](https://github.com/sinedied/multi-scraper/commit/a50e15236d958312aaa82d32107ecc0f92cdf4c1)) 120 | * add support for Anbernic stock OS output format ([2aae6ea](https://github.com/sinedied/multi-scraper/commit/2aae6ea945a9379271c2b801ba70056718b6a649)) 121 | * add support for NextUI output format (closes [#1](https://github.com/sinedied/multi-scraper/issues/1)) ([8c96391](https://github.com/sinedied/multi-scraper/commit/8c963912690871f33fc6275d9a8241832f4b431d)) 122 | * show elapsed time ([287ade2](https://github.com/sinedied/multi-scraper/commit/287ade27379dd95e04b36b8258bd62ea5d971862)) 123 | 124 | # [1.3.0](https://github.com/sinedied/minui-scraper/compare/1.2.0...1.3.0) (2025-02-26) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * height option stretching images ([2fe61e8](https://github.com/sinedied/minui-scraper/commit/2fe61e8a1ebd954d2eba0b6e0fd52cb31e3b3a08)) 130 | * not skipping some multi-disc games ([454b163](https://github.com/sinedied/minui-scraper/commit/454b16382203525b19f95d7d6572bde0c907f96d)) 131 | * rare case when target folder is incorrectly detected as rom folder ([13f04f0](https://github.com/sinedied/minui-scraper/commit/13f04f062cb72283ae3ee1b298f3a17619018fc9)) 132 | * rom folder detection logic ([22791b7](https://github.com/sinedied/minui-scraper/commit/22791b7652928cc1b2bb3dd30bf049670f8b34e8)) 133 | 134 | 135 | ### Features 136 | 137 | * attempt to recover invalid pngs ([8cbd2b7](https://github.com/sinedied/minui-scraper/commit/8cbd2b7699eebf33116e17a7573a2307b8bd3f78)) 138 | 139 | # [1.2.0](https://github.com/sinedied/minui-scraper/compare/1.1.0...1.2.0) (2025-02-25) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * multi-disc with m3u scraping ([4f657bb](https://github.com/sinedied/minui-scraper/commit/4f657bbcab03f1e6e9efba58a484fccafcf523b5)) 145 | * possible psx misdetection ([d654986](https://github.com/sinedied/minui-scraper/commit/d654986ab4ee09d9c744b9043700d1a8864e9fc9)) 146 | * skip individual multi-discs if a m3u is found ([57bab57](https://github.com/sinedied/minui-scraper/commit/57bab572fb4fe3e1c6e02e800bc75cfdbb4b4614)) 147 | 148 | 149 | ### Features 150 | 151 | * add option for composite boxarts ([952947f](https://github.com/sinedied/minui-scraper/commit/952947f6455003ff81db16b0e733aacb39f6ed80)) 152 | * add option to select art type (boxart, snap or title) ([36d6222](https://github.com/sinedied/minui-scraper/commit/36d6222d2e3e18f3abc51e0efd9b35ba23ef7d15)) 153 | * add support for many additional machines ([94f13cd](https://github.com/sinedied/minui-scraper/commit/94f13cd3d2e4d5716a9ec670a036b0d3cd205ebc)) 154 | 155 | # [1.1.0](https://github.com/sinedied/minui-scraper/compare/1.0.0...1.1.0) (2025-02-24) 156 | 157 | 158 | ### Features 159 | 160 | * change default width to 300 ([3636ff5](https://github.com/sinedied/minui-scraper/commit/3636ff512095857be7e9524571fe63264be7f9cb)) 161 | 162 | # 1.0.0 (2025-02-24) 163 | 164 | 165 | ### Bug Fixes 166 | 167 | * escape invalid characters ([4d540db](https://github.com/sinedied/minui-scraper/commit/4d540dbb43348dfdc0505689dc80e4917b586ae0)) 168 | * incorrect machine detection ([e6d8ed0](https://github.com/sinedied/minui-scraper/commit/e6d8ed0886d943a833b24767d76eba0ba8b11bf7)) 169 | * matcher heuristic ([56820c9](https://github.com/sinedied/minui-scraper/commit/56820c920ee396b821f260c6cd8cebb2c8843c41)) 170 | * skip unreadable images ([31f35a0](https://github.com/sinedied/minui-scraper/commit/31f35a02fc324a21b538d58a00069db01122ab5b)) 171 | * target path is rom folder ([83a10c0](https://github.com/sinedied/minui-scraper/commit/83a10c085e1b6a4547750eac68abc647e081d884)) 172 | 173 | 174 | ### Features 175 | 176 | * add --cleanup option ([305c019](https://github.com/sinedied/minui-scraper/commit/305c019b77ca06c8b884536f0ba2c9ef0027cf22)) 177 | * add AI matcher ([6e9535c](https://github.com/sinedied/minui-scraper/commit/6e9535c5840a2bd1bdb5290c2ca2614149eb4971)) 178 | * add fallback match ([9ebab3e](https://github.com/sinedied/minui-scraper/commit/9ebab3e197170f443da9b850fe954460d5d3f970)) 179 | * add region order option ([6741126](https://github.com/sinedied/minui-scraper/commit/674112693331343c501d625492b46b8035788fbc)) 180 | * allow zipped roms ([b22c442](https://github.com/sinedied/minui-scraper/commit/b22c442d4d3b00c935a2b5f64bec67e134e1d7ed)) 181 | * complete minimal scraping ([14603e4](https://github.com/sinedied/minui-scraper/commit/14603e4e0cd276db7a13f369a752db37b664bf69)) 182 | * handle options ([49c8215](https://github.com/sinedied/minui-scraper/commit/49c82151dec20d07abc618302fb801c2f7c29961)) 183 | * implement cli ([fc74dc8](https://github.com/sinedied/minui-scraper/commit/fc74dc8c4d8e04134050f6e6026153eaf333aca3)) 184 | * improve search logic ([7512094](https://github.com/sinedied/minui-scraper/commit/75120940daf3b0311d88773a73f848f39ecbca10)) 185 | * initial commit ([59a4390](https://github.com/sinedied/minui-scraper/commit/59a43909ae93697a5fcabb15b56081593cf3ee66)) 186 | * list machine images ([97412bc](https://github.com/sinedied/minui-scraper/commit/97412bc7bb3213ce064b403de52a5334f09d2579)) 187 | * print stats ([c2f743d](https://github.com/sinedied/minui-scraper/commit/c2f743d6030948746dae4cb97b38cd2754c0243f)) 188 | * wip implmentation ([42128d6](https://github.com/sinedied/minui-scraper/commit/42128d677d3d501c5dc346436ddc61210ab1cfcc)) 189 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-bullseye 2 | 3 | # Install dependencies 4 | RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* 5 | 6 | # Install Ollama 7 | RUN curl -fsSL https://ollama.com/install.sh | sh 8 | 9 | # Set working directory 10 | WORKDIR /app 11 | 12 | # Copy project files 13 | COPY . . 14 | 15 | # Install dependencies, build and install the CLI 16 | RUN npm install && npm run build && npm install -g . 17 | 18 | # Copy the entrypoint script 19 | COPY scripts/entrypoint.sh /entrypoint.sh 20 | RUN chmod +x /entrypoint.sh 21 | 22 | # Use the script as the entrypoint 23 | ENTRYPOINT ["/entrypoint.sh"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Yohan Lasorsa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎨 mini-scraper 2 | 3 | [![NPM version](https://img.shields.io/npm/v/@sinedied/mini-scraper.svg)](https://www.npmjs.com/package/@sinedied/mini-scraper) 4 | [![Build Status](https://github.com/sinedied/mini-scraper/workflows/build/badge.svg)](https://github.com/sinedied/mini-scraper/actions) 5 | ![Node version](https://img.shields.io/node/v/@sinedied/mini-scraper.svg) 6 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 8 | 9 | picture of a scraped boxart 10 | 11 | Artwork scraper for [MinUI](https://github.com/shauninman/MinUI), [NextUI](https://github.com/LoveRetro/NextUI) and [muOS](https://muos.dev/). 12 | 13 | > [!NOTE] 14 | > MinUI does't officially support boxarts, but still has [some support for it as stated by its author](https://www.reddit.com/r/SBCGaming/comments/1hycyqx/minui_box_art/). 15 | 16 | **Features:** 17 | - Scrapes boxart for your ROMs, in a compatible format with multiple frontends/OSes 18 | - No account needed, uses [libretro thumbnails](https://github.com/libretro-thumbnails/libretro-thumbnails) 19 | - Optionally uses local AI with [Ollama](https://ollama.com/) for better boxart matching 20 | - No configuration needed 21 | 22 | ## Intallation 23 | 24 | Requires [Node.js](https://nodejs.org/), and optionally [Ollama](https://ollama.com/) for AI matching. You need to install these to be able to use the scraper. If you don't want to install these, you also have the option to use [Docker](#running-with-docker). 25 | 26 | This tool works with a Command Line Interface (CLI), and need to be installed and run from a terminal application. 27 | 28 | Install the CLI globally by opening a terminal and running the following command: 29 | 30 | ```bash 31 | npm install -g @sinedied/mini-scraper 32 | ``` 33 | 34 | To run the scraper, open a terminal and use the following command: 35 | 36 | ```bash 37 | mscraper [options] 38 | ``` 39 | 40 | Explanation: 41 | - ``: This is the path to the directory containing your ROMs. 42 | - `[options]`: Replace this with the command-line arguments to be passed to the scraper. 43 | 44 | ## Options 45 | 46 | When running the scraper, you can pass the following options: 47 | 48 | - `-w, --width `: Max width of the image (default: 300) 49 | - `-h, --height `: Max height of the image 50 | - `-t, --type `: Type of image to scrape (can be `boxart`, `snap`, `title`, `box+snap`, `box+title`) (default: `boxart`) 51 | - `-o, --output `: Artwork format (can be (`minui`, `nextui`, `muos`, `anbernic`) (default: `minui`) 52 | - `-a, --ai`: Use AI for advanced matching (default: false) 53 | - `-m, --ai-model `: Ollama model to use for AI matching (default: `gemma2:2b`) 54 | - `-r, --regions `: Preferred regions to use for AI matching (default: `World,Europe,USA,Japan`) 55 | - `-f, --force`: Force scraping over existing images 56 | - `--cleanup`: Removes all scraped images in target folder 57 | - `--verbose`: Show detailed logs 58 | - `-v, --version`: Show current version 59 | 60 | > [!TIP] 61 | > Max width must be adjusted depending of the device and output format, the default works well for Trimui Brick. For 640x480 devices, try with `--width 200`. 62 | 63 | ## Example 64 | 65 | ```bash 66 | mscraper myroms --width 300 --ai 67 | ``` 68 | 69 | This will scrape the ROMs in the `myroms` folder with a max image width of 300 and using AI for advanced matching. 70 | 71 | ## Running with Docker 72 | 73 | Alternatively, you can run the scraper using Docker. This is useful if you don't want to install Node.js or Ollama on your system. 74 | 75 | First, you need to have Docker installed on your system. You can download and install Docker from the [official website](https://www.docker.com). 76 | 77 | Then, you need to clone the repository and navigate to the project directory to build the Docker image by running the following command: 78 | 79 | ```bash 80 | docker build -t mini-scraper . 81 | ``` 82 | 83 | Then, you can run the scraper with the following command: 84 | 85 | ```bash 86 | docker run --rm -v :/roms mini-scraper /roms [options] 87 | ``` 88 | 89 | Explanation: 90 | - `--rm`: This removes the container after it has finished running. 91 | - `-v :/roms`: This mounts your ROMs directory to the /roms directory inside the container. Replace with the actual path to your ROMs. 92 | - `mini-scraper`: This is the name of the Docker image. 93 | - `/roms`: This is the directory inside the container where the ROMs are mounted. 94 | - `[options]`: Replace this with the command-line arguments to be passed to the scraper. 95 | 96 | 97 | ## Supported Systems 98 | 99 | The following systems are supported for scraping: 100 | 101 |
102 | Click to expand 103 | 104 | - Nintendo - Game Boy Color 105 | - Nintendo - Game Boy Advance 106 | - Nintendo - Game Boy 107 | - Nintendo - Super Nintendo Entertainment System 108 | - Nintendo - Nintendo 64DD 109 | - Nintendo - Nintendo 64 110 | - Nintendo - Family Computer Disk System 111 | - Nintendo - Nintendo Entertainment System 112 | - Nintendo - Nintendo DSi 113 | - Nintendo - Nintendo DS 114 | - Nintendo - Pokemon Mini 115 | - Nintendo - Virtual Boy 116 | - Handheld Electronic Game 117 | - Sega - 32X 118 | - Sega - Dreamcast 119 | - Sega - Mega Drive - Genesis 120 | - Sega - Mega-CD - Sega CD 121 | - Sega - Game Gear 122 | - Sega - Master System - Mark III 123 | - Sega - Saturn 124 | - Sega - Naomi 2 125 | - Sega - Naomi 126 | - Sony - PlayStation 127 | - Sony - PlayStation Portable 128 | - Amstrad - CPC 129 | - Atari - 2600 130 | - Atari - 5200 131 | - Atari - 7800 132 | - Atari - Jaguar 133 | - Atari - Lynx 134 | - Atari - ST 135 | - Bandai - WonderSwan Color 136 | - Bandai - WonderSwan 137 | - Coleco - ColecoVision 138 | - Commodore - Amiga 139 | - Commodore - VIC-20 140 | - Commodore - 64 141 | - FBNeo - Arcade Games 142 | - GCE - Vectrex 143 | - GamePark - GP32 144 | - MAME 145 | - Microsoft - MSX 146 | - Mattel - Intellivision 147 | - NEC - PC Engine CD - TurboGrafx-CD 148 | - NEC - PC Engine SuperGrafx 149 | - NEC - PC Engine - TurboGrafx 16 150 | - SNK - Neo Geo CD 151 | - SNK - Neo Geo Pocket Color 152 | - SNK - Neo Geo Pocket 153 | - SNK - Neo Geo 154 | - Magnavox - Odyssey2 155 | - TIC-80 156 | - Sharp - X68000 157 | - Watara - Supervision 158 | - DOS 159 | - DOOM 160 | - ScummVM 161 | - Atomiswave 162 | 163 |
164 | -------------------------------------------------------------------------------- /bin/mscraper.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { run } from '../lib/cli.js'; 3 | 4 | run(); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sinedied/mini-scraper", 3 | "description": "Artwork scraper for handheld emulators.", 4 | "version": "1.5.1", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/sinedied/mini-scraper.git" 9 | }, 10 | "bin": { 11 | "mscraper": "bin/mscraper.js" 12 | }, 13 | "exports": "./lib/index.js", 14 | "types": "./lib/index.d.ts", 15 | "scripts": { 16 | "start": "npm run build && node \"./bin/mscraper.js\"", 17 | "build": "npm run clean -s && tsc", 18 | "build:watch": "tsc -w --sourceMap", 19 | "lint": "xo", 20 | "lint:fix": "xo --fix", 21 | "test": "xo && npm run start test", 22 | "clean": "rm -rf lib *.tgz", 23 | "release:check": "semantic-release --dry-run" 24 | }, 25 | "keywords": [ 26 | "minui", 27 | "nextui", 28 | "muos", 29 | "scraper", 30 | "boxart", 31 | "ai" 32 | ], 33 | "author": { 34 | "name": "Yohan Lasorsa", 35 | "url": "https://bsky.app/profile/sinedied.bsky.social" 36 | }, 37 | "homepage": "https://github.com/sinedied/mini-scraper", 38 | "bugs": { 39 | "url": "https://github.com/sinedied/mini-scraper/issues" 40 | }, 41 | "license": "MIT", 42 | "engines": { 43 | "node": ">=20.0.0", 44 | "npm": ">=10.0.0" 45 | }, 46 | "dependencies": { 47 | "commander": "^13.1.0", 48 | "debug": "^4.4.0", 49 | "fast-glob": "^3.3.3", 50 | "fast-png": "^6.3.0", 51 | "fastest-levenshtein": "^1.0.16", 52 | "string-comparison": "^1.3.0", 53 | "jimp": "^1.6.0", 54 | "ollama": "^0.5.13", 55 | "update-notifier": "^7.3.1" 56 | }, 57 | "devDependencies": { 58 | "@types/debug": "^4.1.12", 59 | "@types/update-notifier": "^6.0.8", 60 | "semantic-release": "^24.2.3", 61 | "semantic-release-npm-github": "^5.0.0", 62 | "typescript": "^5.8.2", 63 | "xo": "^0.60.0" 64 | }, 65 | "release": { 66 | "extends": "semantic-release-npm-github", 67 | "branches": "main" 68 | }, 69 | "prettier": { 70 | "trailingComma": "none", 71 | "bracketSpacing": true 72 | }, 73 | "xo": { 74 | "space": true, 75 | "prettier": true, 76 | "envs": [ 77 | "node" 78 | ], 79 | "rules": { 80 | "no-await-in-loop": "off", 81 | "@typescript-eslint/naming-convention": "off", 82 | "@typescript-eslint/no-unsafe-assignment": "off", 83 | "@typescript-eslint/no-unsafe-argument": "off", 84 | "unicorn/no-process-exit": "off", 85 | "unicorn/prevent-abbreviations": "off", 86 | "unicorn/no-array-callback-reference": "off", 87 | "max-params": [ 88 | "warn", 89 | 5 90 | ] 91 | } 92 | }, 93 | "files": [ 94 | "bin", 95 | "lib" 96 | ], 97 | "publishConfig": { 98 | "access": "public" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/pic.jpg -------------------------------------------------------------------------------- /scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on error 4 | set -e 5 | 6 | # Variables 7 | params=("$@") 8 | model_name="gemma2:2b" 9 | model_required=false 10 | 11 | # Check if AI is required and get the model name 12 | for arg in "$@"; do 13 | if [[ "$arg" == "-a" || "$arg" == "--ai" ]]; then 14 | model_required=true 15 | elif [[ "$arg" == "-m" || "$arg" == "--ai-model" ]]; then 16 | model_name="$2" 17 | fi 18 | shift 19 | done 20 | 21 | # If AI is required, start Ollama and download the model 22 | if $model_required; then 23 | # Start Ollama in the background 24 | ollama serve & 25 | 26 | # Wait for Ollama to be ready 27 | until curl -s http://localhost:11434/api/tags > /dev/null; do 28 | echo "Waiting for Ollama to start..." 29 | sleep 2 30 | done 31 | 32 | # Download the model 33 | ollama pull "$model_name" 34 | fi 35 | 36 | # Run mscraper with the provided arguments 37 | exec mscraper "${params[@]}" 38 | -------------------------------------------------------------------------------- /scripts/minui2nextui.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Get path from argument or use current directory 5 | TARGET_PATH="${1:-.}" 6 | 7 | echo "Looking for .res folders in: $TARGET_PATH" 8 | 9 | # Find all .res directories and rename them to .media 10 | find "$TARGET_PATH" -type d -name ".res" | while read -r dir; do 11 | new_dir="${dir%.res}.media" 12 | echo "Renaming: $dir → $new_dir" 13 | mv "$dir" "$new_dir" 14 | done 15 | 16 | echo "Finished renaming folders" 17 | -------------------------------------------------------------------------------- /src/art.ts: -------------------------------------------------------------------------------- 1 | export enum ArtType { 2 | Boxart = 'Named_Boxarts', 3 | Snap = 'Named_Snaps', 4 | Title = 'Named_Titles' 5 | } 6 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import fs from 'node:fs/promises'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { join, dirname, basename } from 'node:path'; 5 | import { program } from 'commander'; 6 | import debug from 'debug'; 7 | import glob from 'fast-glob'; 8 | import updateNotifier from 'update-notifier'; 9 | import { isRomFolder, scrapeFolder } from './libretro.js'; 10 | import { type Options } from './options.js'; 11 | import { checkOllama } from './ollama.js'; 12 | import { stats } from './stats.js'; 13 | import { getOutputFormat } from './format/format.js'; 14 | 15 | const __dirname = dirname(fileURLToPath(import.meta.url)); 16 | 17 | export async function run(args: string[] = process.argv) { 18 | const file = await fs.readFile(join(__dirname, '..', 'package.json'), 'utf8'); 19 | const packageJson = JSON.parse(file); 20 | 21 | updateNotifier({ pkg: packageJson }).notify(); 22 | 23 | if (args.includes('--verbose')) { 24 | debug.enable('*'); 25 | } 26 | 27 | program 28 | .name(basename(process.argv[1])) 29 | .description(packageJson.description) 30 | .argument('', 'Path to the folder containing the ROMs') 31 | .option('-w, --width ', 'Max width of the image', Number.parseFloat, 300) 32 | .option('-h, --height ', 'Max height of the image', Number.parseFloat) 33 | .option('-t, --type ', 'Art type (boxart, snap, title, box+snap, box+title)', 'boxart') 34 | .option('-o, --output ', 'Artwork format (minui, nextui, muos, anbernic)', 'minui') 35 | .option('-a, --ai', 'Use AI for advanced matching', false) 36 | .option('-m, --ai-model ', 'Ollama model to use for AI matching', 'gemma2:2b') 37 | .option('-r, --regions ', 'Preferred regions to use for AI matching', 'World,Europe,USA,Japan') 38 | .option('-f, --force', 'Force scraping over existing images') 39 | .option('--cleanup', 'Removes all scraped images in target folder') 40 | .option('--verbose', 'Show detailed logs') 41 | .version(packageJson.version, '-v, --version', 'Show current version') 42 | .helpCommand(false) 43 | .allowExcessArguments(false) 44 | .action(async (targetPath: string, options: Options) => { 45 | stats.startTime = Date.now(); 46 | process.chdir(targetPath); 47 | 48 | let romFolders: string[] = []; 49 | const targetFolder = basename(targetPath); 50 | if (isRomFolder(targetFolder)) { 51 | debug(`Target folder "${targetFolder}" is a ROM folder`); 52 | romFolders.push(targetFolder); 53 | process.chdir('..'); 54 | } else { 55 | const allFolders = await glob(['*'], { onlyDirectories: true }); 56 | romFolders = allFolders.filter(isRomFolder); 57 | debug(`Found ${romFolders.length} ROM folders`); 58 | } 59 | 60 | if (romFolders.length === 0) { 61 | console.info('No ROM folders found'); 62 | return; 63 | } 64 | 65 | const log = debug('cli'); 66 | log('Found ROM folders:', romFolders); 67 | 68 | if (options.cleanup) { 69 | const format = await getOutputFormat(options); 70 | await format.cleanupArtwork('.', romFolders, options); 71 | return; 72 | } 73 | 74 | if (options.ai) { 75 | const ollama = await checkOllama(options.aiModel); 76 | if (!ollama) { 77 | process.exitCode = 1; 78 | return; 79 | } 80 | } 81 | 82 | for (const folder of romFolders) { 83 | await scrapeFolder(folder, options); 84 | console.info('--------------------------------'); 85 | } 86 | 87 | const elapsed = Date.now() - stats.startTime; 88 | const seconds = Math.floor((elapsed % (1000 * 60)) / 1000); 89 | const minutes = Math.floor((elapsed % (1000 * 60 * 60)) / (1000 * 60)); 90 | 91 | console.info(`Scraped ${romFolders.length} folders (in ${minutes}m ${seconds}s)`); 92 | console.info(`- ${stats.matches.perfect} perfect matches`); 93 | console.info(`- ${stats.matches.partial} partial matches`); 94 | if (options.ai) console.info(`- ${stats.matches.ai} AI matches`); 95 | console.info(`- ${stats.matches.none} not found`); 96 | if (stats.skipped) console.info(`- ${stats.skipped} existing`); 97 | }); 98 | 99 | program.parse(args); 100 | } 101 | -------------------------------------------------------------------------------- /src/file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | 3 | export async function pathExists(targetPath: string) { 4 | try { 5 | await fs.access(targetPath); 6 | return true; 7 | } catch { 8 | return false; 9 | } 10 | } 11 | 12 | export function sanitizeName(name: string) { 13 | return name.replaceAll(/^\d+\)\s*/g, '').replaceAll(/[&*/:`<>?|"]/g, '_'); 14 | } 15 | -------------------------------------------------------------------------------- /src/format/anbernic.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import createDebug from 'debug'; 4 | import glob from 'fast-glob'; 5 | import { getArtTypes } from '../libretro.js'; 6 | import { type Options } from '../options.js'; 7 | import { composeImageTo, resizeImageTo } from '../image.js'; 8 | import { type ArtType } from '../art.js'; 9 | 10 | const debug = createDebug('anbernic'); 11 | const imgsFolder = 'Imgs'; 12 | 13 | export async function useSeparateArtworks(_options: Options) { 14 | return false; 15 | } 16 | 17 | export async function getArtPath(filePath: string, _machine: string, _type?: ArtType) { 18 | const fileName = path.basename(filePath, path.extname(filePath)); 19 | return path.join(path.dirname(filePath), imgsFolder, `${fileName}.png`); 20 | } 21 | 22 | export async function exportArtwork( 23 | art1Url: string | undefined, 24 | art2Url: string | undefined, 25 | artPath: string, 26 | options: Options 27 | ) { 28 | const artTypes = getArtTypes(options); 29 | if (artTypes.art2 && (art1Url ?? art2Url)) { 30 | debug(`Found art URL(s): "${art1Url}" / "${art2Url}"`); 31 | await composeImageTo(art1Url, art2Url, artPath, { width: options.width, height: options.height }); 32 | } else if (art1Url) { 33 | debug(`Found art URL: "${art1Url}"`); 34 | await resizeImageTo(art1Url, artPath, { width: options.width, height: options.height }); 35 | } else { 36 | return false; 37 | } 38 | 39 | return true; 40 | } 41 | 42 | export async function cleanupArtwork(targetPath: string, _romFolders: string[], _options: Options) { 43 | const imgsFolders = await glob([`**/${imgsFolder}`], { onlyDirectories: true, cwd: targetPath }); 44 | await Promise.all(imgsFolders.map(async (imgsFolder) => fs.rm(imgsFolder, { recursive: true }))); 45 | console.info(`Removed ${imgsFolders.length} ${imgsFolder} folders`); 46 | } 47 | 48 | const anbernic = { 49 | useSeparateArtworks, 50 | getArtPath, 51 | exportArtwork, 52 | cleanupArtwork 53 | }; 54 | 55 | export default anbernic; 56 | -------------------------------------------------------------------------------- /src/format/format.ts: -------------------------------------------------------------------------------- 1 | import { type ArtType } from '../art.js'; 2 | import { type Options } from '../options.js'; 3 | 4 | export enum Format { 5 | MinUI = 'minui', 6 | NextUI = 'nextui', 7 | MuOS = 'muos', 8 | Anbernic = 'anbernic' 9 | } 10 | 11 | export type SeparateArtworksFunction = (options: Options) => Promise; 12 | 13 | export type PrepareMachineFunction = (folderPath: string, machine: string, options: Options) => Promise; 14 | 15 | export type OutputPathFunction = (filePath: string, machine: string, type?: ArtType) => Promise; 16 | 17 | export type OutputArtworkFunction = ( 18 | url1: string | undefined, 19 | url2: string | undefined, 20 | artPath: string, 21 | options: Options 22 | ) => Promise; 23 | 24 | export type CleanupArtworkFunction = (targetPath: string, romFolders: string[], options: Options) => Promise; 25 | 26 | export type OutputFormat = { 27 | useSeparateArtworks: SeparateArtworksFunction; 28 | prepareMachine?: PrepareMachineFunction; 29 | getArtPath: OutputPathFunction; 30 | exportArtwork: OutputArtworkFunction; 31 | cleanupArtwork: CleanupArtworkFunction; 32 | }; 33 | 34 | export async function getOutputFormat(options: Options): Promise { 35 | switch (options.output as Format) { 36 | case Format.MinUI: { 37 | const minui = await import('./minui.js'); 38 | return minui.default; 39 | } 40 | 41 | case Format.NextUI: { 42 | const nextui = await import('./nextui.js'); 43 | return nextui.default; 44 | } 45 | 46 | case Format.MuOS: { 47 | const muos = await import('./muos.js'); 48 | return muos.default; 49 | } 50 | 51 | case Format.Anbernic: { 52 | const anbernic = await import('./anbernic.js'); 53 | return anbernic.default; 54 | } 55 | 56 | // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check 57 | default: { 58 | throw new Error(`Unknown format: ${options.output}`); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/format/minui.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import createDebug from 'debug'; 4 | import glob from 'fast-glob'; 5 | import { getArtTypes } from '../libretro.js'; 6 | import { type Options } from '../options.js'; 7 | import { composeImageTo, resizeImageTo } from '../image.js'; 8 | import { type ArtType } from '../art.js'; 9 | 10 | const debug = createDebug('minui'); 11 | const resFolder = '.res'; 12 | 13 | export async function useSeparateArtworks(_options: Options) { 14 | return false; 15 | } 16 | 17 | export async function getArtPath(filePath: string, _machine: string, _type?: ArtType) { 18 | return path.join(path.dirname(filePath), resFolder, `${path.basename(filePath)}.png`); 19 | } 20 | 21 | export async function exportArtwork( 22 | art1Url: string | undefined, 23 | art2Url: string | undefined, 24 | artPath: string, 25 | options: Options 26 | ) { 27 | const artTypes = getArtTypes(options); 28 | if (artTypes.art2 && (art1Url ?? art2Url)) { 29 | debug(`Found art URL(s): "${art1Url}" / "${art2Url}"`); 30 | await composeImageTo(art1Url, art2Url, artPath, { width: options.width, height: options.height }); 31 | } else if (art1Url) { 32 | debug(`Found art URL: "${art1Url}"`); 33 | await resizeImageTo(art1Url, artPath, { width: options.width, height: options.height }); 34 | } else { 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | export async function cleanupArtwork(targetPath: string, _romFolders: string[], _options: Options) { 42 | const resFolders = await glob([`**/${resFolder}`], { onlyDirectories: true, cwd: targetPath }); 43 | await Promise.all(resFolders.map(async (resFolder) => fs.rm(resFolder, { recursive: true }))); 44 | console.info(`Removed ${resFolders.length} ${resFolder} folders`); 45 | } 46 | 47 | const minui = { 48 | useSeparateArtworks, 49 | getArtPath, 50 | exportArtwork, 51 | cleanupArtwork 52 | }; 53 | 54 | export default minui; 55 | -------------------------------------------------------------------------------- /src/format/muos.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import createDebug from 'debug'; 4 | import { type Options } from '../options.js'; 5 | import { composeImageTo, resizeImageTo } from '../image.js'; 6 | import { ArtType } from '../art.js'; 7 | import { getArtTypes, getMachine } from '../libretro.js'; 8 | import { pathExists } from '../file.js'; 9 | 10 | const debug = createDebug('muos'); 11 | 12 | const separateArtworks = false; 13 | const artworkBasePath = '/MUOS/info/catalogue/'; 14 | // Maps machines to MUOS catalogue folders 15 | const machineFolders: Record = { 16 | 'Nintendo - Game Boy Color': 'Nintendo Game Boy Color', 17 | 'Nintendo - Game Boy Advance': 'Nintendo Game Boy Advance', 18 | 'Nintendo - Game Boy': 'Nintendo Game Boy', 19 | 'Nintendo - Super Nintendo Entertainment System': 'Nintendo SNES-SFC', 20 | 'Nintendo - Nintendo 64DD': 'Nintendo N64', 21 | 'Nintendo - Nintendo 64': 'Nintendo N64', 22 | 'Nintendo - Family Computer Disk System': 'Nintendo NES-Famicom', 23 | 'Nintendo - Nintendo Entertainment System': 'Nintendo NES-Famicom', 24 | 'Nintendo - Nintendo DSi': 'Nintendo DS', 25 | 'Nintendo - Nintendo DS': 'Nintendo DS', 26 | 'Nintendo - Pokemon Mini': 'Nintendo Pokemon Mini', 27 | 'Nintendo - Virtual Boy': 'Nintendo Virtual Boy', 28 | 'Handheld Electronic Game': 'Handheld Electronic - Game and Watch', 29 | 'Sega - 32X': 'Sega 32X', 30 | 'Sega - Dreamcast': 'Sega Dreamcast', 31 | 'Sega - Mega-CD - Sega CD': 'Sega Mega CD - Sega CD', 32 | 'Sega - Mega Drive - Genesis': 'Sega Mega Drive - Genesis', 33 | 'Sega - Game Gear': 'Sega Game Gear', 34 | 'Sega - Master System - Mark III': 'Sega Master System', 35 | 'Sega - Saturn': 'Sega Saturn', 36 | 'Sony - PlayStation Portable': 'Sony Playstation Portable', 37 | 'Sony - PlayStation': 'Sony PlayStation', 38 | 'Sega - Naomi 2': 'Sega Atomiswave Naomi', 39 | 'Sega - Naomi': 'Sega Atomiswave Naomi', 40 | 'Amstrad - CPC': 'Amstrad', 41 | 'Atari - ST': 'Atari ST-STE-TT-Falcon', 42 | 'Atari - 2600': 'Atari 2600', 43 | 'Atari - 5200': 'Atari 5200', 44 | 'Atari - 7800': 'Atari 7800', 45 | 'Atari - Jaguar': 'Atari Jaguar', 46 | 'Atari - Lynx': 'Atari Lynx', 47 | 'Bandai - WonderSwan Color': 'Bandai WonderSwan-Color', 48 | 'Bandai - WonderSwan': 'Bandai WonderSwan-Color', 49 | 'Coleco - ColecoVision': 'ColecoVision', 50 | 'Commodore - Amiga': 'Commodore Amiga', 51 | 'Commodore - VIC-20': 'Commodore VIC-20', 52 | 'Commodore - 64': 'Commodore C64', 53 | 'FBNeo - Arcade Games': 'Arcade', 54 | 'GCE - Vectrex': 'GCE-Vectrex', 55 | 'GamePark - GP32': undefined, 56 | MAME: 'Arcade', 57 | 'Microsoft - MSX2': 'Microsoft - MSX', 58 | 'Microsoft - MSX': 'Microsoft - MSX', 59 | 'Mattel - Intellivision': 'Mattel - Intellivision', 60 | 'NEC - PC Engine CD - TurboGrafx-CD': 'NEC PC Engine CD', 61 | 'NEC - PC Engine SuperGrafx': 'NEC PC Engine SuperGrafx', 62 | 'NEC - PC Engine - TurboGrafx 16': 'NEC PC Engine', 63 | 'SNK - Neo Geo CD': 'SNK Neo Geo CD', 64 | 'SNK - Neo Geo Pocket Color': 'SNK Neo Geo Pocket - Color', 65 | 'SNK - Neo Geo Pocket': 'SNK Neo Geo Pocket - Color', 66 | 'SNK - Neo Geo': 'SNK Neo Geo', 67 | 'Magnavox - Odyssey2': 'Odyssey2 - VideoPac', 68 | 'TIC-80': 'TIC-80', 69 | 'Sharp - X68000': 'Sharp X68000', 70 | 'Watara - Supervision': 'Watara Supervision', 71 | DOS: 'DOS', 72 | DOOM: 'DOOM', 73 | ScummVM: 'ScummVM', 74 | Atomiswave: 'Sega Atomiswave Naomi' 75 | }; 76 | // Maps artwork types to folders 77 | const artFolders: Record = { 78 | [ArtType.Boxart]: 'box', 79 | [ArtType.Snap]: 'preview', 80 | [ArtType.Title]: 'preview' 81 | }; 82 | const iniFiles = ['theme/override/muxfavourite.txt', 'theme/override/muxhistory.txt', 'theme/override/muxplore.txt']; 83 | let volumeRootPath: string | undefined; 84 | let hasUpdatedIni = false; 85 | 86 | export async function useSeparateArtworks(_options: Options) { 87 | return separateArtworks; 88 | } 89 | 90 | export async function getArtPath(filePath: string, machine: string, type: ArtType = ArtType.Boxart) { 91 | const machineFolder = machineFolders[machine]; 92 | if (!machineFolder) { 93 | throw new Error(`Machine "${machine}" not supported by MUOS`); 94 | } 95 | 96 | if (!type) { 97 | throw new Error(`Artwork type not specified for "${machine}"`); 98 | } 99 | 100 | const fileName = path.basename(filePath, path.extname(filePath)); 101 | const root = await findVolumeRoot(filePath); 102 | return path.join(root, artworkBasePath, machineFolder, artFolders[type], `${fileName}.png`); 103 | } 104 | 105 | export async function exportArtwork( 106 | art1Url: string | undefined, 107 | art2Url: string | undefined, 108 | artPath: string, 109 | options: Options 110 | ) { 111 | if (separateArtworks) { 112 | if (!art1Url) return false; 113 | 114 | debug(`Found art URL: "${art1Url}"`); 115 | await resizeImageTo(art1Url, artPath, { width: options.width, height: options.height }); 116 | return true; 117 | } 118 | 119 | const artTypes = getArtTypes(options); 120 | if (artTypes.art2 && (art1Url ?? art2Url)) { 121 | debug(`Found art URL(s): "${art1Url}" / "${art2Url}"`); 122 | await composeImageTo(art1Url, art2Url, artPath, { width: options.width, height: options.height }); 123 | } else if (art1Url) { 124 | debug(`Found art URL: "${art1Url}"`); 125 | await resizeImageTo(art1Url, artPath, { width: options.width, height: options.height }); 126 | } else { 127 | return false; 128 | } 129 | 130 | return true; 131 | } 132 | 133 | export async function cleanupArtwork(targetPath: string, romFolders: string[], _options: Options) { 134 | let removed = 0; 135 | for (const romFolder of romFolders) { 136 | const machine = getMachine(romFolder, true) ?? ''; 137 | const machineFolder = machineFolders[machine]; 138 | if (!machineFolder) { 139 | debug(`Machine "${machine}" not supported by MUOS, skipping`); 140 | continue; 141 | } 142 | 143 | const root = await findVolumeRoot(targetPath); 144 | const machineArtPath = path.join(root, artworkBasePath, machineFolder); 145 | await fs.rm(machineArtPath, { recursive: true }); 146 | removed++; 147 | } 148 | 149 | console.info(`Removed ${removed} folders`); 150 | } 151 | 152 | export async function prepareMachine(folderPath: string, _machine: string, options: Options) { 153 | const root = await findVolumeRoot(folderPath); 154 | await updateIniFiles(root, options); 155 | } 156 | 157 | async function findVolumeRoot(targetPath: string) { 158 | if (volumeRootPath) { 159 | return volumeRootPath; 160 | } 161 | 162 | const absolutePath = path.resolve(targetPath); 163 | const parts = absolutePath.split(path.sep); 164 | for (let i = parts.length; i > 0; i--) { 165 | const currentPath = parts.slice(0, i).join(path.sep); 166 | const autorunPath = path.join(currentPath, 'autorun.inf'); 167 | try { 168 | await fs.access(autorunPath); 169 | volumeRootPath = currentPath; 170 | debug(`Found muOS root at "${currentPath}"`); 171 | return currentPath; 172 | } catch { 173 | // Ignore errors 174 | } 175 | } 176 | 177 | debug(`Could not determine muOS root for "${targetPath}" (looking for autorun.inf)`); 178 | volumeRootPath = path.dirname(targetPath); 179 | return volumeRootPath; 180 | } 181 | 182 | async function updateIniFiles(rootPath: string, options: Options) { 183 | // Only do it once per run 184 | if (hasUpdatedIni) return; 185 | hasUpdatedIni = true; 186 | 187 | const iniFilesToUpdate = iniFiles.map((file) => path.join(rootPath, file)); 188 | for (const iniFile of iniFilesToUpdate) { 189 | try { 190 | await fs.mkdir(path.dirname(iniFile), { recursive: true }); 191 | 192 | if (await pathExists(iniFile)) { 193 | let content = await fs.readFile(iniFile, 'utf8'); 194 | if (content.includes('CONTENT_WIDTH')) { 195 | content = content.replace(/^CONTENT_WIDTH=\d+$/m, `CONTENT_WIDTH=${options.width}`); 196 | console.info(`Updated theme override: "${iniFile}" with CONTENT_WIDTH=${options.width}`); 197 | await fs.writeFile(iniFile, content); 198 | } else { 199 | console.info(`Theme override file "${iniFile}" exists, but CONTENT_WIDTH not set, skipping update`); 200 | } 201 | 202 | continue; 203 | } 204 | 205 | // Create the file if it doesn't exist 206 | const content = `[misc]\nCONTENT_WIDTH=${options.width}\n`; 207 | await fs.writeFile(iniFile, content); 208 | console.info(`Created theme override: "${iniFile}"`); 209 | } catch (_error) { 210 | const error = _error as Error; 211 | console.error(`Failed to update theme override file "${iniFile}": ${error?.message}`); 212 | } 213 | } 214 | } 215 | 216 | const muos = { 217 | useSeparateArtworks, 218 | getArtPath, 219 | prepareMachine, 220 | exportArtwork, 221 | cleanupArtwork 222 | }; 223 | 224 | export default muos; 225 | -------------------------------------------------------------------------------- /src/format/nextui.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import createDebug from 'debug'; 4 | import glob from 'fast-glob'; 5 | import { getArtTypes } from '../libretro.js'; 6 | import { type Options } from '../options.js'; 7 | import { composeImageTo, resizeImageTo } from '../image.js'; 8 | import { type ArtType } from '../art.js'; 9 | 10 | const debug = createDebug('nextui'); 11 | const mediaFolder = '.media'; 12 | 13 | export async function useSeparateArtworks(_options: Options) { 14 | return false; 15 | } 16 | 17 | export async function getArtPath(filePath: string, _machine: string, _type?: ArtType) { 18 | const fileName = path.basename(filePath, path.extname(filePath)); 19 | return path.join(path.dirname(filePath), mediaFolder, `${fileName}.png`); 20 | } 21 | 22 | export async function exportArtwork( 23 | art1Url: string | undefined, 24 | art2Url: string | undefined, 25 | artPath: string, 26 | options: Options 27 | ) { 28 | const artTypes = getArtTypes(options); 29 | if (artTypes.art2 && (art1Url ?? art2Url)) { 30 | debug(`Found art URL(s): "${art1Url}" / "${art2Url}"`); 31 | await composeImageTo(art1Url, art2Url, artPath, { width: options.width, height: options.height }); 32 | } else if (art1Url) { 33 | debug(`Found art URL: "${art1Url}"`); 34 | await resizeImageTo(art1Url, artPath, { width: options.width, height: options.height }); 35 | } else { 36 | return false; 37 | } 38 | 39 | return true; 40 | } 41 | 42 | export async function cleanupArtwork(targetPath: string, _romFolders: string[], _options: Options) { 43 | const mediaFolders = await glob([`**/${mediaFolder}`], { onlyDirectories: true, cwd: targetPath }); 44 | await Promise.all(mediaFolders.map(async (mediaFolder) => fs.rm(mediaFolder, { recursive: true }))); 45 | console.info(`Removed ${mediaFolders.length} ${mediaFolder} folders`); 46 | } 47 | 48 | const nextui = { 49 | useSeparateArtworks, 50 | getArtPath, 51 | exportArtwork, 52 | cleanupArtwork 53 | }; 54 | 55 | export default nextui; 56 | -------------------------------------------------------------------------------- /src/image.ts: -------------------------------------------------------------------------------- 1 | import { mkdir } from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { Jimp } from 'jimp'; 4 | import { decode, encode } from 'fast-png'; 5 | import createDebug from 'debug'; 6 | 7 | const debug = createDebug('image'); 8 | 9 | export type Size = { 10 | width?: number; 11 | height?: number; 12 | }; 13 | 14 | export async function loadImage(url: string) { 15 | url = encodeURI(url); 16 | try { 17 | return await Jimp.read(url); 18 | } catch (error_: unknown) { 19 | const error = error_ as Error; 20 | if (error.message?.includes('unrecognised content at end of stream')) { 21 | debug(`Failed to load image from "${url}", trying to fix incorrect PNG...`); 22 | const response = await fetch(url); 23 | const buffer = await response.arrayBuffer(); 24 | const png = decode(buffer); 25 | const fixedPng = encode(png); 26 | return Jimp.read(Buffer.from(fixedPng)); 27 | } 28 | 29 | throw error; 30 | } 31 | } 32 | 33 | export async function resizeImageTo(url: string, destination: string, size?: Size) { 34 | try { 35 | const width = size?.width ?? 300; 36 | const height = size?.height; 37 | const image = await loadImage(url); 38 | const isLargerThanTaller = !height || image.bitmap.width >= image.bitmap.height; 39 | const imgWidth = isLargerThanTaller ? width : undefined; 40 | const imgHeight = isLargerThanTaller ? undefined : height; 41 | await mkdir(path.dirname(destination), { recursive: true }); 42 | await image.resize({ w: imgWidth!, h: imgHeight }).write(destination as `${string}.${string}`); 43 | } catch (error: any) { 44 | console.error(`Failed to download art from "${url}": ${error.message}`); 45 | } 46 | } 47 | 48 | export async function composeImageTo( 49 | url1: string | undefined, 50 | url2: string | undefined, 51 | destination: string, 52 | size?: Size 53 | ) { 54 | try { 55 | const width = size?.width ?? 300; 56 | const margin = Math.round((width * 5) / 100); 57 | const height = size?.height ?? width; 58 | await mkdir(path.dirname(destination), { recursive: true }); 59 | const image1 = url1 ? await loadImage(url1) : undefined; 60 | const image2 = url2 ? await loadImage(url2) : undefined; 61 | const image = new Jimp({ width, height, color: 0x00_00_00_00 }); 62 | 63 | if (image2) { 64 | const img2Width = image2.bitmap.width >= image2.bitmap.height ? width - margin : undefined; 65 | const img2Height = image2.bitmap.width < image2.bitmap.height ? height - margin : undefined; 66 | image2.resize({ w: img2Width!, h: img2Height }); 67 | image.composite(image2, 0, (height - image2.bitmap.height) / 2 - margin); 68 | } 69 | 70 | if (image1) { 71 | const img1Width = image1.bitmap.width >= image1.bitmap.height ? width / 2 - margin : undefined; 72 | const img1Height = image1.bitmap.width < image1.bitmap.height ? height / 2 - margin : undefined; 73 | image1.resize({ w: img1Width!, h: img1Height }); 74 | image.composite(image1, width - image1.bitmap.width, height - image1.bitmap.height); 75 | } 76 | 77 | await image.write(destination as `${string}.${string}`); 78 | } catch (error: any) { 79 | console.error(`Failed to download art from "${url1}" or "${url2}": ${error.message}`); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './art.js'; 2 | export * from './cli.js'; 3 | export * from './file.js'; 4 | export * from './image.js'; 5 | export * from './libretro.js'; 6 | export * from './machines.js'; 7 | export * from './matcher.js'; 8 | export * from './ollama.js'; 9 | export * from './options.js'; 10 | export * from './stats.js'; 11 | export * from './format/format.js'; 12 | export * as minui from './format/minui.js'; 13 | export * as nextui from './format/nextui.js'; 14 | export * as muos from './format/muos.js'; 15 | export * as anbernic from './format/anbernic.js'; 16 | -------------------------------------------------------------------------------- /src/libretro.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import path from 'node:path'; 3 | import createDebug from 'debug'; 4 | import glob from 'fast-glob'; 5 | import { ArtTypeOption, type Options } from './options.js'; 6 | import { findBestMatch, findFuzzyMatches } from './matcher.js'; 7 | import { stats } from './stats.js'; 8 | import { machines } from './machines.js'; 9 | import { getOutputFormat } from './format/format.js'; 10 | import { ArtType } from './art.js'; 11 | import { pathExists, sanitizeName } from './file.js'; 12 | 13 | const debug = createDebug('libretro'); 14 | 15 | export type MachineCache = Record>>; 16 | 17 | const baseUrl = 'https://thumbnails.libretro.com/'; 18 | const machineCache: MachineCache = {}; 19 | 20 | export function getMachine(file: string, isFolder = false) { 21 | const extension = file.split('.').pop() ?? ''; 22 | const firstComponent = file.split(/\\|\//)[0]; 23 | const machine = Object.entries(machines).find(([_, { extensions, alias }]) => { 24 | return (isFolder || extensions.includes(extension)) && alias.some((a) => firstComponent.includes(a)); 25 | }); 26 | return machine ? machine[0] : undefined; 27 | } 28 | 29 | export function isRomFolder(folderName: string) { 30 | return getMachine(folderName, true) !== undefined; 31 | } 32 | 33 | export async function scrapeFolder(folderPath: string, options: Options) { 34 | debug('Options:', options); 35 | console.info(`Scraping folder: ${folderPath} [Detected: ${getMachine(folderPath, true)}]`); 36 | const files = await glob(['**/*'], { onlyFiles: true, cwd: folderPath }); 37 | let prepared = false; 38 | 39 | for (const file of files) { 40 | try { 41 | const originalFilePath = path.join(folderPath, file); 42 | let filePath = originalFilePath; 43 | if (filePath.endsWith('.m3u')) { 44 | const parentFolder = path.dirname(filePath); 45 | if (parentFolder === folderPath) { 46 | debug(`File is m3u, parent folder is machine folder, continuing anyway: ${filePath}`); 47 | } else { 48 | filePath = parentFolder; 49 | debug(`File is m3u, using parent folder for scraping: ${filePath}`); 50 | } 51 | } else { 52 | // Check if it's a multi-disc, with "Rom Name (Disc 1).any" format, 53 | // with a "Rom Name.m3u" in the same folder 54 | const m3uPath = filePath.replace(/ \(Disc \d+\).+$/, '') + '.m3u'; 55 | if (await pathExists(m3uPath)) { 56 | debug(`File is a multi-disc part, skipping: ${filePath}`); 57 | continue; 58 | } 59 | } 60 | 61 | const machine = getMachine(originalFilePath); 62 | if (!machine) continue; 63 | 64 | const format = await getOutputFormat(options); 65 | if (format.prepareMachine && !prepared) { 66 | await format.prepareMachine(folderPath, machine, options); 67 | prepared = true; 68 | } 69 | 70 | if (await format.useSeparateArtworks(options)) { 71 | const artTypes = getArtTypes(options); 72 | const art1Path = await format.getArtPath(originalFilePath, machine, artTypes.art1); 73 | if ((await pathExists(art1Path)) && !options.force) { 74 | debug(`Art file already exists, skipping "${art1Path}"`); 75 | stats.skipped++; 76 | } else { 77 | debug(`Machine: ${machine} (file: ${filePath})`); 78 | const art1Url = await findArtUrl(filePath, machine, options, artTypes.art1); 79 | const result = await format.exportArtwork(art1Url, undefined, art1Path, options); 80 | if (!result) { 81 | console.info(`No art found for "${filePath}"`); 82 | } 83 | } 84 | 85 | const art2Path = artTypes.art2 ? await format.getArtPath(originalFilePath, machine, artTypes.art2) : undefined; 86 | if (!art2Path) continue; 87 | if ((await pathExists(art2Path)) && !options.force) { 88 | debug(`Art file already exists, skipping "${art2Path}"`); 89 | stats.skipped++; 90 | } else { 91 | debug(`Machine: ${machine} (file: ${filePath})`); 92 | const art2Url = await findArtUrl(filePath, machine, options, artTypes.art2); 93 | const result = await format.exportArtwork(art2Url, undefined, art2Path, options); 94 | if (!result) { 95 | console.info(`No art found for "${filePath}"`); 96 | } 97 | } 98 | } else { 99 | const artPath = await format.getArtPath(originalFilePath, machine); 100 | if ((await pathExists(artPath)) && !options.force) { 101 | debug(`Art file already exists, skipping "${artPath}"`); 102 | stats.skipped++; 103 | continue; 104 | } 105 | 106 | debug(`Machine: ${machine} (file: ${filePath})`); 107 | const artTypes = getArtTypes(options); 108 | const art1Url = await findArtUrl(filePath, machine, options, artTypes.art1); 109 | const art2Url = artTypes.art2 ? await findArtUrl(filePath, machine, options, artTypes.art2) : undefined; 110 | const result = await format.exportArtwork(art1Url, art2Url, artPath, options); 111 | if (!result) { 112 | console.info(`No art found for "${filePath}"`); 113 | } 114 | } 115 | } catch (_error: unknown) { 116 | const error = _error as Error; 117 | console.error(`Error while scraping artwork for file "${file}": ${error.message}`); 118 | } 119 | } 120 | 121 | debug('--------------------------------'); 122 | } 123 | 124 | export async function findArtUrl( 125 | filePath: string, 126 | machine: string, 127 | options: Options, 128 | type: ArtType = ArtType.Boxart, 129 | fallback = true 130 | ): Promise { 131 | let arts = machineCache[machine]?.[type]; 132 | if (!arts) { 133 | debug(`Fetching arts list for "${machine}" (${type})`); 134 | const artsPath = `${baseUrl}${machine}/${type}/`; 135 | const response = await fetch(artsPath); 136 | const text = await response.text(); 137 | arts = 138 | text 139 | .match(//g) 140 | ?.map((a) => a.replace(//, '$1')) 141 | .map((a) => decodeURIComponent(a)) ?? []; 142 | machineCache[machine] ??= {}; 143 | machineCache[machine][type] = arts; 144 | } 145 | 146 | const fileName = path.basename(filePath, path.extname(filePath)); 147 | 148 | // Try exact match 149 | const pngName = sanitizeName(`${fileName}.png`); 150 | if (arts.includes(pngName)) { 151 | debug(`Found exact match for "${fileName}"`); 152 | stats.matches.perfect++; 153 | return `${baseUrl}${machine}/${type}/${pngName}`; 154 | } 155 | 156 | const findMatch = async (name: string) => { 157 | const matches = arts.filter((a) => a.includes(sanitizeName(name))); 158 | if (matches.length > 0) { 159 | const bestMatch = await findBestMatch(name, fileName, matches, options); 160 | return `${baseUrl}${machine}/${type}/${bestMatch}`; 161 | } 162 | 163 | return undefined; 164 | }; 165 | 166 | // Try searching after removing (...) and [...] in the name 167 | let strippedName = fileName.replaceAll(/(\(.*?\)|\[.*?])/g, '').trim(); 168 | let match = await findMatch(strippedName); 169 | if (match) return match; 170 | 171 | // Try searching using fuzzy matching 172 | const matches: string[] = await findFuzzyMatches(sanitizeName(strippedName), arts, options); 173 | if (matches.length > 0) { 174 | const bestMatch = await findBestMatch(strippedName, fileName, matches, options); 175 | return `${baseUrl}${machine}/${type}/${bestMatch}`; 176 | } 177 | 178 | // Try searching after removing DX in the name 179 | strippedName = strippedName.replaceAll('DX', '').trim(); 180 | match = await findMatch(strippedName); 181 | if (match) return match; 182 | 183 | // Try searching after removing substitles using ': ' 184 | strippedName = strippedName.split(': ')[0].trim(); 185 | match = await findMatch(strippedName); 186 | if (match) return match; 187 | 188 | // Try searching after removing substitles using '- ' 189 | strippedName = strippedName.split('- ')[0].trim(); 190 | match = await findMatch(strippedName); 191 | if (match) return match; 192 | 193 | // Try with fallback machines 194 | if (!fallback) return undefined; 195 | const fallbackMachines = machines[machine]?.fallbacks ?? []; 196 | for (const fallbackMachine of fallbackMachines) { 197 | const artUrl = await findArtUrl(filePath, fallbackMachine, options, type, false); 198 | if (artUrl) { 199 | debug(`Found match for "${fileName}" in fallback machine "${fallbackMachine}"`); 200 | return artUrl; 201 | } 202 | 203 | debug(`No match for "${fileName}" in fallback machine "${fallbackMachine}"`); 204 | } 205 | 206 | stats.matches.none++; 207 | return undefined; 208 | } 209 | 210 | export function getArtTypes(options: Options) { 211 | switch (options.type) { 212 | case ArtTypeOption.Boxart: { 213 | return { art1: ArtType.Boxart }; 214 | } 215 | 216 | case ArtTypeOption.Snap: { 217 | return { art1: ArtType.Snap }; 218 | } 219 | 220 | case ArtTypeOption.Title: { 221 | return { art1: ArtType.Title }; 222 | } 223 | 224 | case ArtTypeOption.BoxAndSnap: { 225 | return { art1: ArtType.Boxart, art2: ArtType.Snap }; 226 | } 227 | 228 | case ArtTypeOption.BoxAndTitle: { 229 | return { art1: ArtType.Boxart, art2: ArtType.Title }; 230 | } 231 | 232 | // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check 233 | default: { 234 | console.error(`Invalid art type: "${options.type as any}"`); 235 | process.exit(1); 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/machines.ts: -------------------------------------------------------------------------------- 1 | export type Machine = { 2 | extensions: string[]; 3 | alias: string[]; 4 | fallbacks?: string[]; 5 | folders?: boolean; 6 | }; 7 | 8 | export const machines: Record = { 9 | 'Nintendo - Game Boy Color': { 10 | extensions: ['gbc', 'zip'], 11 | alias: ['GBC', 'Game Boy Color'], 12 | fallbacks: ['Nintendo - Game Boy'] 13 | }, 14 | 'Nintendo - Game Boy Advance': { 15 | extensions: ['gba', 'zip'], 16 | alias: ['GBA', 'Game Boy Advance'] 17 | }, 18 | 'Nintendo - Game Boy': { 19 | extensions: ['gb', 'sgb', 'zip'], 20 | alias: ['GB', 'SGB', 'Game Boy'], 21 | fallbacks: ['Nintendo - Game Boy Color'] 22 | }, 23 | 'Nintendo - Super Nintendo Entertainment System': { 24 | extensions: ['sfc', 'smc', 'zip'], 25 | alias: ['SNES', 'SFC', 'Super Famicom', 'Super Nintendo', 'Super NES'] 26 | }, 27 | 'Nintendo - Nintendo 64DD': { 28 | extensions: ['n64dd', 'zip'], 29 | alias: ['N64DD', 'Nintendo 64DD'], 30 | fallbacks: ['Nintendo - Nintendo 64'] 31 | }, 32 | 'Nintendo - Nintendo 64': { 33 | extensions: ['n64', 'v64', 'zip'], 34 | alias: ['N64', 'Nintendo 64'] 35 | }, 36 | 'Nintendo - Family Computer Disk System': { 37 | extensions: ['fds', 'zip'], 38 | alias: ['FDS', 'Family Computer Disk System', 'Famicom Disk System'] 39 | }, 40 | 'Nintendo - Nintendo DSi': { 41 | extensions: ['dsi', 'zip'], 42 | alias: ['DSi', 'Nintendo DSi'], 43 | fallbacks: ['Nintendo - Nintendo DS'] 44 | }, 45 | 'Nintendo - Nintendo DS': { 46 | extensions: ['nds', 'zip'], 47 | alias: ['DS', 'Nintendo DS'] 48 | }, 49 | 'Nintendo - Nintendo Entertainment System': { 50 | extensions: ['nes', 'zip'], 51 | alias: ['NES', 'FC', 'Famicom', 'Nintendo'] 52 | }, 53 | 'Nintendo - Pokemon Mini': { 54 | extensions: ['pm', 'min', 'zip'], 55 | alias: ['PKM', 'Pokemon Mini'] 56 | }, 57 | 'Nintendo - Virtual Boy': { 58 | extensions: ['vb', 'zip'], 59 | alias: ['VB', 'Virtual Boy'] 60 | }, 61 | 'Handheld Electronic Game': { 62 | extensions: ['mgw', 'zip'], 63 | alias: ['GW', 'Game & Watch'] 64 | }, 65 | 'Sega - 32X': { 66 | extensions: ['32x', 'zip'], 67 | alias: ['32X', 'THIRTYTWOX'] 68 | }, 69 | 'Sega - Dreamcast': { 70 | extensions: ['dc', 'chd', 'gdi', 'm3u'], 71 | alias: ['DC', 'Dreamcast'] 72 | }, 73 | 'Sega - Mega-CD - Sega CD': { 74 | extensions: ['chd', 'iso', 'cue', 'm3u'], 75 | alias: ['MDCD', 'Mega CD', 'Sega CD', 'MegaCD', 'SegaCD'] 76 | }, 77 | 'Sega - Mega Drive - Genesis': { 78 | extensions: ['md', 'gen', 'zip'], 79 | alias: ['MD', 'Mega Drive', 'Genesis'] 80 | }, 81 | 'Sega - Game Gear': { 82 | extensions: ['gg', 'zip'], 83 | alias: ['GG', 'Game Gear'] 84 | }, 85 | 'Sega - Master System - Mark III': { 86 | extensions: ['sms', 'zip'], 87 | alias: ['SMS', 'MS', 'Master System', 'Mark III'] 88 | }, 89 | 'Sega - Saturn': { 90 | extensions: ['chd', 'cue'], 91 | alias: ['Saturn'] 92 | }, 93 | 'Sony - PlayStation Portable': { 94 | extensions: ['iso', 'cso', 'chd', 'm3u', 'pbp'], 95 | alias: ['PSP', 'PlayStation Portable'], 96 | fallbacks: ['Sony - PlayStation'] 97 | }, 98 | 'Sony - PlayStation': { 99 | extensions: ['chd', 'cue', 'm3u', 'pbp'], 100 | alias: ['PS', 'PSX', 'PS1', 'PlayStation'] 101 | }, 102 | 'Sega - Naomi 2': { 103 | extensions: ['zip'], 104 | alias: ['NAOMI2'], 105 | fallbacks: ['Sega - Naomi'] 106 | }, 107 | 'Sega - Naomi': { 108 | extensions: ['zip'], 109 | alias: ['NAOMI'], 110 | fallbacks: ['Sega - Naomi 2'] 111 | }, 112 | 'Amstrad - CPC': { 113 | extensions: ['dsk', 'zip'], 114 | alias: ['CPC', 'Amstrad'] 115 | }, 116 | 'Atari - ST': { 117 | extensions: ['st', 'zip'], 118 | alias: ['ST', 'ATARIST', 'Atari ST'] 119 | }, 120 | 'Atari - 2600': { 121 | extensions: ['a26', 'zip'], 122 | alias: ['A26', '2600', 'Atari 2600', 'Atari'] 123 | }, 124 | 'Atari - 5200': { 125 | extensions: ['a52', 'zip'], 126 | alias: ['A52', '5200', 'Atari 5200'] 127 | }, 128 | 'Atari - 7800': { 129 | extensions: ['a78', 'zip'], 130 | alias: ['A78', '7800', 'Atari 7800'] 131 | }, 132 | 'Atari - Jaguar': { 133 | extensions: ['jag', 'zip'], 134 | alias: ['JAG', 'Jaguar'] 135 | }, 136 | 'Atari - Lynx': { 137 | extensions: ['lynx', 'zip'], 138 | alias: ['LYNX', 'Lynx'] 139 | }, 140 | 'Bandai - WonderSwan Color': { 141 | extensions: ['wsc', 'zip'], 142 | alias: ['WSC', 'WonderSwan Color'], 143 | fallbacks: ['Bandai - WonderSwan'] 144 | }, 145 | 'Bandai - WonderSwan': { 146 | extensions: ['ws', 'zip'], 147 | alias: ['WS', 'WonderSwan'] 148 | }, 149 | 'Coleco - ColecoVision': { 150 | extensions: ['col', 'zip'], 151 | alias: ['COL', 'Coleco', 'ColecoVision'] 152 | }, 153 | 'Commodore - Amiga': { 154 | extensions: ['adf', 'zip'], 155 | alias: ['ADF', 'Amiga'] 156 | }, 157 | 'Commodore - VIC-20': { 158 | extensions: ['v64', 'zip'], 159 | alias: ['VIC'] 160 | }, 161 | 'Commodore - 64': { 162 | extensions: ['d64', 'zip'], 163 | alias: ['D64', 'C64', 'Commodore 64', 'Commodore'] 164 | }, 165 | 'FBNeo - Arcade Games': { 166 | extensions: ['zip'], 167 | alias: ['FBN', 'FBNeo', 'FB Alpha', 'FBA', 'Final Burn Alpha'], 168 | fallbacks: ['MAME'] 169 | }, 170 | 'GCE - Vectrex': { 171 | extensions: ['vec', 'zip'], 172 | alias: ['VEC', 'Vectrex'] 173 | }, 174 | 'GamePark - GP32': { 175 | extensions: ['gp', 'zip'], 176 | alias: ['GP32', 'GamePark'] 177 | }, 178 | MAME: { 179 | extensions: ['zip'], 180 | alias: ['MAME', 'CPS1', 'CPS2', 'CPS3', 'VARCADE'], 181 | fallbacks: ['SNK - Neo Geo'] 182 | }, 183 | 'Microsoft - MSX2': { 184 | extensions: ['msx2', 'zip'], 185 | alias: ['MSX2'] 186 | }, 187 | 'Microsoft - MSX': { 188 | extensions: ['rom', 'zip'], 189 | alias: ['MSX'], 190 | fallbacks: ['Microsoft - MSX2'] 191 | }, 192 | 'Mattel - Intellivision': { 193 | extensions: ['int', 'zip'], 194 | alias: ['INT', 'Intellivision'] 195 | }, 196 | 'NEC - PC Engine CD - TurboGrafx-CD': { 197 | extensions: ['chd', 'cue', 'm3u'], 198 | alias: ['PCECD', 'TGCD', 'PC Engine CD', 'TurboGrafx-CD'] 199 | }, 200 | 'NEC - PC Engine SuperGrafx': { 201 | extensions: ['sgx', 'zip'], 202 | alias: ['SGFX', 'SGX', 'SuperGrafx'] 203 | }, 204 | 'NEC - PC Engine - TurboGrafx 16': { 205 | extensions: ['pce', 'zip'], 206 | alias: ['PCE', 'TG16', 'PC Engine', 'TurboGrafx 16'] 207 | }, 208 | 'SNK - Neo Geo CD': { 209 | extensions: ['chd', 'cue', 'm3u'], 210 | alias: ['NEOCD', 'NGCD', 'Neo Geo CD'] 211 | }, 212 | 'SNK - Neo Geo Pocket Color': { 213 | extensions: ['ngc', 'zip'], 214 | alias: ['NGPC', 'Neo Geo Pocket Color'], 215 | fallbacks: ['SNK - Neo Geo Pocket'] 216 | }, 217 | 'SNK - Neo Geo Pocket': { 218 | extensions: ['ngp', 'zip'], 219 | alias: ['NGP', 'Neo Geo Pocket'] 220 | }, 221 | 'SNK - Neo Geo': { 222 | extensions: ['neogeo', 'zip'], 223 | alias: ['NEOGEO', 'Neo Geo'], 224 | fallbacks: ['MAME'] 225 | }, 226 | 'Magnavox - Odyssey2': { 227 | extensions: ['bin', 'zip'], 228 | alias: ['ODYSSEY'] 229 | }, 230 | 'TIC-80': { 231 | extensions: ['tic', 'zip'], 232 | alias: ['TIC'] 233 | }, 234 | 'Sharp - X68000': { 235 | extensions: ['hdf', 'zip'], 236 | alias: ['X68000'] 237 | }, 238 | 'Watara - Supervision': { 239 | extensions: ['sv', 'zip'], 240 | alias: ['SV', 'Supervision'] 241 | }, 242 | DOS: { 243 | extensions: ['pc', 'dos', 'zip'], 244 | alias: ['DOS'] 245 | }, 246 | DOOM: { 247 | extensions: ['wad', 'zip'], 248 | alias: ['WAD'] 249 | }, 250 | ScummVM: { 251 | extensions: ['scummvm', 'zip'], 252 | alias: ['SCUMM'] 253 | }, 254 | Atomiswave: { 255 | extensions: ['zip'], 256 | alias: ['Atomiswave'] 257 | } 258 | }; 259 | -------------------------------------------------------------------------------- /src/matcher.ts: -------------------------------------------------------------------------------- 1 | import { closest } from 'fastest-levenshtein'; 2 | import stringComparison from 'string-comparison'; 3 | import createDebug from 'debug'; 4 | import { type Options } from './options.js'; 5 | import { getCompletion } from './ollama.js'; 6 | import { stats } from './stats.js'; 7 | 8 | const debug = createDebug('matcher'); 9 | 10 | export async function findBestMatch(search: string, name: string, candidates: string[], options: Options) { 11 | if (!candidates?.length) return undefined; 12 | 13 | if (options?.ai) { 14 | const bestMatch = await findBestMatchWithAi(search, name, candidates, options); 15 | if (bestMatch) return bestMatch; 16 | } 17 | 18 | // Use Levenstein distance after removing (...) and [...] in the name 19 | const strippedCandidates = candidates.map((c) => c.replaceAll(/(\(.*?\)|\[.*?])/g, '').trim()); 20 | const best = closest(search, strippedCandidates); 21 | const bestIndex = strippedCandidates.indexOf(best); 22 | const bestMatch = candidates[bestIndex]; 23 | 24 | console.info(`Partial match for "${name}" (searched: "${search}"): "${bestMatch}"`); 25 | stats.matches.partial++; 26 | return bestMatch; 27 | } 28 | 29 | export async function findBestMatchWithAi( 30 | search: string, 31 | name: string, 32 | candidates: string[], 33 | options: Options, 34 | retries = 2 35 | ): Promise { 36 | const prompt = ` 37 | ## Candidates 38 | ${candidates.map((c) => `${c}`).join('\n')} 39 | 40 | ## Instructions 41 | Find the best matching image for the ROM name "${name}" in the listed candidates. 42 | If a direct match isn't available, use the closest match trying to translate the name in english. 43 | For example, "Pokemon - Version Or (France) (SGB Enhanced)" should match "Pokemon - Gold Version (USA, Europe) (SGB Enhanced) (GB Compatible).png". 44 | Game sequels MUST NOT match, "Sonic" is NOT the same as "Sonic 2". 45 | When multiple regions are available, prefer the one that matches the region of the ROM if possible. 46 | If the region is not available, use this order of preference: ${options.regions}. 47 | If no close match is found, return null. 48 | 49 | ## Output 50 | Answer with JSON using the following format: 51 | { 52 | "bestMatch": "" 53 | }`; 54 | 55 | const response = await getCompletion(prompt, options.aiModel); 56 | debug('AI response:', response); 57 | 58 | const bestMatch = response?.bestMatch; 59 | if (!bestMatch) { 60 | debug(`AI failed to find a match for "${name}" (searched: "${search}")`); 61 | return undefined; 62 | } 63 | 64 | if (!candidates.includes(bestMatch)) { 65 | debug(`AI found a match for "${name}" (searched: "${search}"), but it's not a candidate: "${bestMatch}"`); 66 | if (retries <= 0) return undefined; 67 | 68 | debug(`Retrying AI match for "${name}" (Tries left: ${retries})`); 69 | return findBestMatchWithAi(search, name, candidates, options, retries - 1); 70 | } 71 | 72 | console.info(`AI match for "${name}" (searched: "${search}"): "${bestMatch}"`); 73 | stats.matches.ai++; 74 | return bestMatch; 75 | } 76 | 77 | export async function findFuzzyMatches(search: string, candidates: string[], _options: Options) { 78 | // Remove (...) and [...] in candidates' name 79 | const strippedCandidates = candidates.map((c) => c.replaceAll(/(\(.*?\)|\[.*?])/g, '').trim()); 80 | const jaroMatches = new Set( 81 | strippedCandidates 82 | .map((c) => ({ c, similarity: stringComparison.jaroWinkler.similarity(search, c) })) 83 | .filter(({ similarity }) => similarity >= 0.85) 84 | .sort((a, b) => b.similarity - a.similarity) 85 | .slice(0, 25) 86 | .map(({ c }) => c) 87 | ); 88 | const matches: string[] = []; 89 | for (const [index, strippedCandidate] of strippedCandidates.entries()) { 90 | if (jaroMatches.has(strippedCandidate)) { 91 | matches.push(candidates[index]); 92 | } 93 | } 94 | 95 | debug(`Fuzzy matches for "${search}":`, matches); 96 | 97 | return matches; 98 | } 99 | -------------------------------------------------------------------------------- /src/ollama.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { execSync } from 'node:child_process'; 3 | import { createInterface } from 'node:readline'; 4 | import ollama from 'ollama'; 5 | import createDebug from 'debug'; 6 | 7 | const debug = createDebug('ollama'); 8 | 9 | export async function getCompletion(prompt: string, model: string, retryCount = 2) { 10 | debug('Requesting completion for prompt:', prompt); 11 | const response = await ollama.chat({ 12 | model, 13 | messages: [{ role: 'user', content: prompt }], 14 | options: { temperature: 0.3 }, 15 | format: 'json' 16 | }); 17 | const content = response?.message?.content; 18 | 19 | try { 20 | const jsonContent = JSON.parse(content) as Record; 21 | return jsonContent; 22 | } catch { 23 | debug('Failed to parse JSON response:', content); 24 | if (retryCount > 0) { 25 | debug('Retrying, remaining attempts:', retryCount); 26 | return getCompletion(prompt, model, retryCount - 1); 27 | } 28 | 29 | return undefined; 30 | } 31 | } 32 | 33 | export async function checkOllama(model: string) { 34 | try { 35 | await ollama.list(); 36 | debug('Ollama is installed'); 37 | } catch { 38 | console.error( 39 | `Ollama is not installed or running, but --ai option is enabled.\nPlease install it from https://ollama.com/download.` 40 | ); 41 | return false; 42 | } 43 | 44 | let hasModel = false; 45 | 46 | try { 47 | const response = await ollama.show({ model }); 48 | hasModel = true; 49 | debug(`Model "${model}" available`); 50 | } catch (error: any) { 51 | if (error.status_code !== 404) { 52 | console.error(`Could not connect to Ollama API, please try again.`); 53 | return false; 54 | } 55 | } 56 | 57 | if (!hasModel) { 58 | const confirm = await askForConfirmation(`Model "${model}" not found. Do you want to download it?`); 59 | if (!confirm) { 60 | throw new Error(`Model "${model}" is not available.\nPlease run "ollama pull ${model}" to download it.`); 61 | } 62 | 63 | try { 64 | console.info(`Downloading model "${model}"...`); 65 | runCommandSync(`ollama pull ${model}`); 66 | } catch (error: any) { 67 | console.error(`Failed to download model "${model}".\n${error.message}`); 68 | return false; 69 | } 70 | } 71 | 72 | return true; 73 | } 74 | 75 | export function runCommandSync(command: string) { 76 | execSync(command, { stdio: 'inherit', encoding: 'utf8' }); 77 | } 78 | 79 | export async function askForInput(question: string): Promise { 80 | return new Promise((resolve, _reject) => { 81 | const read = createInterface({ 82 | input: process.stdin, 83 | output: process.stdout 84 | }); 85 | read.question(question, (answer) => { 86 | read.close(); 87 | resolve(answer); 88 | }); 89 | }); 90 | } 91 | 92 | export async function askForConfirmation(question: string): Promise { 93 | const answer = await askForInput(`${question} [Y/n] `); 94 | return answer.toLowerCase() !== 'n'; 95 | } 96 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | export enum ArtTypeOption { 2 | Boxart = 'boxart', 3 | Snap = 'snap', 4 | Title = 'title', 5 | BoxAndSnap = 'box+snap', 6 | BoxAndTitle = 'box+title' 7 | } 8 | 9 | export type Options = { 10 | width: number; 11 | height?: number; 12 | type: ArtTypeOption; 13 | force?: boolean; 14 | ai?: boolean; 15 | aiModel: string; 16 | regions: string; 17 | output: string; 18 | cleanup?: boolean; 19 | }; 20 | -------------------------------------------------------------------------------- /src/stats.ts: -------------------------------------------------------------------------------- 1 | export const stats = { 2 | matches: { 3 | perfect: 0, 4 | partial: 0, 5 | ai: 0, 6 | none: 0 7 | }, 8 | skipped: 0, 9 | startTime: 0 10 | }; 11 | -------------------------------------------------------------------------------- /test/DOS/Dark Forces.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/DOS/Dark Forces.zip -------------------------------------------------------------------------------- /test/DOS/Doom.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/DOS/Doom.zip -------------------------------------------------------------------------------- /test/GBC/Pokemon - Version Argent (France) (SGB Enhanced).zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/GBC/Pokemon - Version Argent (France) (SGB Enhanced).zip -------------------------------------------------------------------------------- /test/GBC/Wario Land 3 (World) (En,Ja).zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/GBC/Wario Land 3 (World) (En,Ja).zip -------------------------------------------------------------------------------- /test/Game Boy (GB)/ Best/Tetris.gb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/Game Boy (GB)/ Best/Tetris.gb -------------------------------------------------------------------------------- /test/Game Boy (GB)/Addams Family, The (USA).gb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/Game Boy (GB)/Addams Family, The (USA).gb -------------------------------------------------------------------------------- /test/Game Boy (GB)/Hacks/Metroid II - Return of Samus (Map).zip: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/Game Boy (GB)/Hacks/Super Mario Land 2 - 6 Golden Coins DX.zip: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/Game Boy (GB)/Hacks/Super Mario Land DX.zip: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/Game Boy (GB)/Hacks/Tetris - Rosy Retrospection.zip: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/PS/Colony Wars (France)/Colony Wars (France).m3u: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/PS/Colony Wars (France)/Colony Wars (France).m3u -------------------------------------------------------------------------------- /test/PS/Final Fantasy VII (Europe) (Disc 1).chd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/PS/Final Fantasy VII (Europe) (Disc 1).chd -------------------------------------------------------------------------------- /test/PS/Final Fantasy VII (Europe).m3u: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/PS/Final Fantasy VII (Europe).m3u -------------------------------------------------------------------------------- /test/PS/Final Fantasy VII (France)/Final Fantasy VII (France) (Disc 1).chd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/PS/Final Fantasy VII (France)/Final Fantasy VII (France) (Disc 1).chd -------------------------------------------------------------------------------- /test/PS/Final Fantasy VII (France)/Final Fantasy VII (France).m3u: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/PS/Final Fantasy VII (France)/Final Fantasy VII (France).m3u -------------------------------------------------------------------------------- /test/PS/Moto Racer 2 (Europe) (En,Fr,De,Es,It,Sv).chd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/PS/Moto Racer 2 (Europe) (En,Fr,De,Es,It,Sv).chd -------------------------------------------------------------------------------- /test/SNES/Super Mario World.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/SNES/Super Mario World.zip -------------------------------------------------------------------------------- /test/autorun.inf: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/ignore/ignoreme.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/mini-scraper/e4bd31fcc2be9cdf526a014c1bc63841ede96dae/test/ignore/ignoreme.txt -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "target": "es2022", 5 | "module": "nodenext", 6 | "moduleResolution": "nodenext", 7 | "lib": ["es2022", "dom"], 8 | "declaration": true, 9 | "outDir": "lib", 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true 15 | }, 16 | "include": ["./src"] 17 | } 18 | --------------------------------------------------------------------------------