├── .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 | [](https://www.npmjs.com/package/@sinedied/mini-scraper)
4 | [](https://github.com/sinedied/mini-scraper/actions)
5 | 
6 | [](https://github.com/sindresorhus/xo)
7 | [](LICENSE)
8 |
9 |
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 |
--------------------------------------------------------------------------------