Choose the folder that holds your ROMs, or any empty folder to browse the full game lists. ROM Shelf keeps its database in a visible “ROM Shelf Data” folder inside it, so your favorites, ratings and batches travel with the folder. Everything is read on this computer. Then browse, remove duplicates, and build batches like "Favorite PS2" or "Perfect for Miyoo Mini" to copy out with artwork.
Works in Chrome or Edge on a computer. Your files stay where they are.
HX._jump(g);
if(S.cfg.timeline) setTimeout(() => HXA.toggleTimeline(true), 0);
/* ===================== batches ===================== */
let batchRev = 0; const touchBatch = () => { batchRev++; };
const activeBatch = () => S.user.batches.find(b => b.id === S.user.active) || null;
const newBatch = (name, profile = 'custom') => {
const p = PROFILES[profile], b = { id: 'b' + Date.now().toString(36) + Math.floor(Math.random() * 99), name, profile, layout: p.layout, items: [], exclude: [], systems: [], maxFileMB: p.maxFileMB, capacityGB: p.capacityGB,
auto: { on: false, favOnly: false, minRating: 0, worksOnly: false, perSystem: 0, defining: false, cat: '' }, opts: { art: true, snap: true, video: true, gamelist: true, m3u: true } };
b.systems = profileSystems(profile); return b;
};
function profileSystems(profile){ const p = PROFILES[profile] || PROFILES.custom; return SYSTEMS.filter(s => s.id !== 'other' && s.tier <= p.maxTier && !p.exclude.includes(s.id)).map(s => s.id); }
const resCache = new Map();
function resolveBatch(b){
const hit = resCache.get(b.id); if(hit && hit.rev === batchRev) return hit.res;
const best = new Map(); S.games.forEach(g => { if(g.isBest && !(g.isBios && S.cfg.hideBios)) best.set(g.uid, g); });
const allowed = new Set(b.systems), picked = new Map(), excluded = [], ex = new Set(b.exclude || []);
const why = g => !allowed.has(g.system) ? 'Not in this batch’s systems (' + (SYS[g.system] || SYS.other).short + ')' : (b.maxFileMB && g.size > b.maxFileMB * 1048576) ? 'Larger than ' + b.maxFileMB + ' MB (' + fmtSize(g.size) + ')' : gstate(g) === 'broken' ? 'Marked broken' : '';
for(const uid of b.items){ const g = best.get(uid); if(!g) continue; const r = why(g); if(r) excluded.push({ g, reason: r }); else picked.set(uid, { g, src: 'manual' }); }
let presetInfo = null;
if(b.preset && PRESETS[b.preset]){ // a batch made from a list of titles
presetInfo = presetResolve(b.preset, allowed, best);
presetInfo.games.forEach(g => { if(ex.has(g.uid) || picked.has(g.uid)) return; const r = why(g); if(r) excluded.push({ g, reason: r }); else picked.set(g.uid, { g, src: 'list' }); });
}
if(b.auto.on){
let total = [...picked.values()].reduce((n, x) => n + x.g.size, 0); const cap = b.capacityGB ? b.capacityGB * 2 ** 30 : Infinity, per = new Map();
picked.forEach(x => per.set(x.g.system, (per.get(x.g.system) || 0) + 1));
const cands = [...best.values()].filter(g => !picked.has(g.uid) && !ex.has(g.uid) && !g.identicalTo && !why(g) && allowed.has(g.system));
const fl = g => (b.auto.favOnly ? isFav(g) : true) && rating(g) >= (b.auto.minRating || 0) && (b.auto.worksOnly ? gstate(g) === 'works' : true) && (!b.auto.defining || isDefining(g)) && (!b.auto.cat || catOf(g) === b.auto.cat);
let pool = cands.filter(fl); if(b.auto.defining){ const keep = new Set(dedupeDefining(pool.filter(isDefining)).map(g => g.uid)); pool = pool.filter(g => !isDefining(g) || keep.has(g.uid)); }
const ranked = pool.sort((a, c) => (isFav(c) - isFav(a)) || (isDefining(c) - isDefining(a)) || (rating(c) - rating(a)) || ((gstate(c) === 'works') - (gstate(a) === 'works')) || (c.score - a.score) || natural(a.sortKey, c.sortKey));
for(const g of ranked){
if(b.auto.perSystem && (per.get(g.system) || 0) >= b.auto.perSystem) continue;
if(total + g.size > cap) continue;
picked.set(g.uid, { g, src: 'auto' }); total += g.size; per.set(g.system, (per.get(g.system) || 0) + 1);
}
}
for(const uid of ex) picked.delete(uid);
const games = [...picked.values()].map(x => Object.assign(x.g, {})), src = new Map([...picked].map(([k, v]) => [k, v.src]));
games.sort((a, c) => natural(a.sortKey, c.sortKey));
const res = { games, excluded, src, size: games.reduce((n, g) => n + g.size, 0), preset: presetInfo };
resCache.set(b.id, { rev: batchRev, res }); return res;
}
function inBatch(g){ const b = activeBatch(); if(!b) return false; return resolveBatch(b).games.some(x => x.uid === g.uid); }
function updateBatchChip(){} // the counts that used to sit in the top bar are worked out when the menu opens
function menuCounts(){
const oc = S.games.length ? organizeCount() : { total: 0, roots: 0, loose: 0 }, nd = [...S.groups.values()].filter(x => x.length > 1).length, sv = nd ? potentialSavings() : { now: 0, max: 0 }, bt = activeBatch();
return { oc, nd, sv, bt, bn: bt ? resolveBatch(bt).games.length : 0 };
}
function toggleBatch(g){
if(!g) return; if(S.virtual){ toast('Batches need your ROM folder. This is a read-only view.', 3500); return; } if(g.missing){ toast('You do not have this game yet, so it cannot go in a batch.', 3500); return; }
let b = activeBatch(); if(!b){ b = newBatch('My batch'); S.user.batches.push(b); S.user.active = b.id; }
const res = resolveBatch(b), has = res.games.some(x => x.uid === g.uid);
if(has){ b.items = b.items.filter(u => u !== g.uid); if(res.src.get(g.uid) === 'auto' || res.src.get(g.uid) === 'list'){ b.exclude = b.exclude || []; if(!b.exclude.includes(g.uid)) b.exclude.push(g.uid); } toast('Removed from “' + b.name + '”'); }
else { if(!b.items.includes(g.uid)) b.items.push(g.uid); b.exclude = (b.exclude || []).filter(u => u !== g.uid); const r = resolveBatch(b); const stillOut = !r.games.some(x => x.uid === g.uid); toast(stillOut ? 'Added, but this batch’s settings leave it out: ' + (r.excluded.find(x => x.g.uid === g.uid) || { reason: 'excluded' }).reason : 'Added to “' + b.name + '”'); }
touchBatch(); saveUser(); updateBatchChip(); renderList(); scheduleDetail(0); if($('#drawer').classList.contains('open')) renderDrawer(); if(S.view === 'systems') renderCarousel();
}
/* ===================== drawer ===================== */
function toggleDrawer(open){ const d = $('#drawer'); const o = open === undefined ? !d.classList.contains('open') : open; d.classList.toggle('open', o); if(o) renderDrawer(); }
function renderDrawer(){
const d = $('#drawer'), sc = d.querySelector('.scroll') ? d.querySelector('.scroll').scrollTop : 0, b = activeBatch();
let h = '
Batches
';
if(!S.games.length) h += '
Open a ROM folder first.
';
else {
h += '
' +
'
' +
'
' +
'
' +
'
';
if(b){
const r = resolveBatch(b), cap = b.capacityGB ? b.capacityGB * 2 ** 30 : 0, pct = cap ? Math.min(100, r.size / cap * 100) : 0, prof = PROFILES[b.profile];
const bySys = new Map(); r.games.forEach(g => bySys.set(g.system, (bySys.get(g.system) || 0) + 1));
h += '
Picks one best version of each game. Order: favorites, then star rating, then games you marked working, then region and revision. Stops when the card is full. Games marked broken are always skipped.
' : '
Off: the batch contains only what you add with Enter.
Artwork: ' + (b.opts.art || b.opts.snap ? withLocal + ' games have local art; the rest are fetched from the libretro library when it has them' + (S.cfg.onlineArt ? '' : ' (online art is off in Settings)') : 'not included') + '. Videos: ' + (b.opts.video ? vids + ' found in your linked folder' : 'not included') + '.
' +
(b.layout === 'onion' ? '
OnionOS style folder names are typical, not guaranteed. Open the exported Roms folder next to your card’s own to confirm the names match.
' : '') + '
' +
'
Files that already exist at the destination with the same size are skipped, so you can run it again to resume.
Emulator installs ' + ld.games.length + ' games exist in more than one install. These installs are kept intact, because taking single files out would break them.
Install sizes: ' + ld.installs.map(([r, n]) => '' + esc(r) + ' ' + fmtSize(n)).join(', ') + '. To free the space, keep the install you use and delete the other folder yourself.
';
} else if(C.tab === 'exact'){
if(!C.exact){
$('#cleanLead').textContent = 'Finds files with identical contents even when their names differ. It compares sizes first, then reads the files, so it can take a while on a big library.';
h = '
' + S.games.length.toLocaleString() + ' releases to compare.
';
} else {
$('#cleanLead').textContent = C.exact.length ? C.exact.length + ' sets of identical files. Same size and same contents (large files are compared by samples from the start, middle and end).' : 'No identical files found.';
h = C.exact.slice(0, C.show).map(g => '
').join('');
}
} else if(C.tab === 'junk'){
$('#cleanLead').textContent = junk.length ? junk.length + ' files look like leftovers: bad dumps, tiny stubs, betas and demos that have no final release, and games outside your region list or marked broken.' : 'No junk or leftovers found.';
h = junk.slice(0, C.show).map(j => '
'; body.scrollTop = sc;
const mv = buildMoves(), by = k => new Set(mv.filter(m => m.kind === k).map(m => m.g.id)).size;
const aside = new Set(mv.filter(m => m.kind !== 'organize').map(m => m.g.id)), asideBytes = [...aside].reduce((n, id) => n + S.byId.get(id).size, 0);
$('#cleanSum').textContent = (aside.size ? aside.size + ' games (' + fmtSize(asideBytes) + ') to move aside' : 'Nothing to move aside') + (by('organize') ? ' · ' + by('organize') + ' to organize' : '') + (aside.size ? ' [' + [['ver', 'versions'], ['exact', 'identical'], ['junk', 'junk']].map(([k, t]) => by(k) ? by(k) + ' ' + t : '').filter(Boolean).join(', ') + ']' : '');
$('#cleanGo').disabled = !mv.length || C.busy; $('#cleanGo').textContent = mv.length ? 'Move ' + mv.length + ' files' : 'Nothing to move'; $('#cleanSh').disabled = $('#cleanPs').disabled = !mv.length;
}
$('#cLvl').addEventListener('input', e => { C.level = +e.target.value; S.cfg.cleanLevel = C.level; saveCfg(); C.on.clear(); renderClean(); });
$('#dlgClean .tabs').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; C.tab = b.dataset.tab; C.show = 150; $$('#dlgClean .tabs button').forEach(x => x.classList.toggle('on', x === b)); renderClean(); });
$('#cleanBody').addEventListener('change', e => {
const t = e.target;
if(t.dataset.mv !== undefined) C.on.set(t.dataset.mv, t.checked);
else if(t.dataset.xon !== undefined) C.on.set('x' + t.dataset.xon, t.checked);
else if(t.dataset.xkeep){ const [g, i] = t.dataset.xkeep.split(':').map(Number); C.exKeep.set(g, i); }
else if(t.dataset.cleanup !== undefined){ C.alsoCleanup = t.checked; }
else if(t.id === 'oBase'){ S.cfg.romBase = t.value; saveCfg(); C.on = new Map([...C.on].filter(([k]) => !k.startsWith('r:'))); }
else if(t.id === 'oNames'){ S.cfg.folderNames = t.value; saveCfg(); C.on = new Map([...C.on].filter(([k]) => !k.startsWith('r:'))); }
else if(t.dataset.ron !== undefined) C.on.set('r:' + t.dataset.ron, t.checked);
else if(t.dataset.art){ C.art[t.dataset.art] = t.checked; }
else if(t.id === 'artLay'){ C.artLayout = t.value; }
else if(t.dataset.son) C.strayOn.set(t.dataset.son, t.checked);
else if(t.dataset.sto){ C.strayTo.set(t.dataset.sto, t.value); C.strayOn.set(t.dataset.sto, t.value !== 'other'); }
renderClean();
});
$('#cleanBody').addEventListener('click', e => {
if(e.target.id === 'cleanMore'){ C.show += 150; renderClean(); }
if(e.target.id === 'cleanScan') scanIdentical();
if(e.target.id === 'oBaseAuto'){ delete S.cfg.romBase; saveCfg(); C.on = new Map([...C.on].filter(([k]) => !k.startsWith('r:'))); renderClean(); return; }
if(e.target.id === 'artGo') saveArtInLibrary();
if(e.target.id === 'artStop'){ exp.cancel = true; }
const of = e.target.closest('[data-of]'); if(of){ const g = S.byId.get(of.dataset.of); if(g) openGameFolder(g); return; }
const mk = e.target.closest('[data-mk]'); if(mk){ const k = mk.dataset.mk, i = k.lastIndexOf(':'); const uid = k.slice(0, i), idx = +k.slice(i + 1); C.keep.set(uid, idx); (S.groups.get(uid) || []).forEach(g => C.on.delete(g.id)); renderClean(); }
});
/* identical-file scan */
async function sampledHash2(f){
const sz = f.size, L = 2 * 2 ** 20;
const blob = await f.handle.getFile(); let buf;
if(sz <= 128 * 2 ** 20) buf = new Uint8Array(await blob.arrayBuffer());
else { const parts = [blob.slice(0, L), blob.slice(Math.floor(sz / 2 - L / 2), Math.floor(sz / 2 + L / 2)), blob.slice(sz - L, sz)]; const ab = await Promise.all(parts.map(p => p.arrayBuffer())); buf = new Uint8Array(L * 3); ab.forEach((a, i) => buf.set(new Uint8Array(a), i * L)); }
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', buf)); return sz + ':' + [...d].slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function scanIdentical(){
C.busy = true; renderClean();
const main = g => g.files.reduce((a, b) => b.size > a.size ? b : a), bySize = new Map();
S.games.filter(g => g.size > 0 && !g.locked && !g.isFolder).forEach(g => push(bySize, main(g).size, g));
const sets = [...bySize.values()].filter(a => a.length > 1), total = sets.reduce((n, a) => n + a.length, 0); let done = 0; const found = [];
for(const set of sets){
const hs = new Map();
for(const g of set){ try{ push(hs, await sampledHash2(main(g)), g); }catch(e){} done++; if(done % 3 === 0){ const b = $('#scanBar'), t = $('#scanTxt'); if(b) b.style.width = (done / total * 100) + '%'; if(t) t.textContent = done + ' of ' + total + ' files compared'; await tick(); } }
for(const items of hs.values()) if(items.length > 1){ items.sort((a, b) => (b.score - a.score) || (b.isBest - a.isBest) || a.primary.path.length - b.primary.path.length); found.push({ i: found.length, items }); }
}
S.games.forEach(g => { g.identicalTo = null; });
found.forEach(grp => grp.items.slice(1).forEach(g => { g.identicalTo = grp.items[0]; }));
C.on = new Map([...C.on].filter(([k]) => !k.startsWith('x')));
C.exact = found.sort((a, b) => b.items[0].size * b.items.length - a.items[0].size * a.items.length).map((g, i) => ({ ...g, i })); C.exKeep = new Map(C.exact.map(g => [g.i, 0]));
C.busy = false; renderClean();
}
/* ===================== system folders: rename, merge and place ===================== */
function systemRootOf(e){
const segs = e.dir.split('/').filter(Boolean);
for(let i = segs.length - 1; i >= 0; i--){ const a = sysFromFolder(segs[i]); if(a) return { sys: a, root: segs.slice(0, i + 1).join('/') }; }
return null;
}
// If every system folder already sits inside one parent (like roms/), keep that parent instead of hoisting everything to the top.
function romBase(){
if(S.cfg.romBase !== undefined && S.cfg.romBase !== null) return String(S.cfg.romBase).replace(/^\/+|\/+$/g, '');
return suggestedBase();
}
function suggestedBase(){
const parents = new Map(); let total = 0;
for(const g of S.games){ if(g.isBios) continue; const r = systemRootOf(g.primary); if(r){ const p = dirOf(r.root); parents.set(p, (parents.get(p) || 0) + g.size); total += g.size; } }
const top = [...parents].sort((a, b) => b[1] - a[1])[0];
return top && top[0] && top[1] >= total * 0.35 ? top[0] : '';
}
// Handheld SD cards use their own folder names (FC, SFC, PS, FIFTYTWOHUNDRED…). Renaming those would hide the games from the device.
function detectDeviceStyle(){
if(/miyoo|ambernic|anbernic|onion|retroid|muos|arkos|spruce|minui|\brg\d{2,3}/i.test(S.rootName || '')) return true;
const cnt = new Map(); let total = 0;
for(const g of S.games){ if(g.isBios) continue; const r = systemRootOf(g.primary); total++; if(r){ const e = cnt.get(r.root) || { sys: r.sys, n: 0 }; e.n++; cnt.set(r.root, e); } }
let codeRoots = 0, codeGames = 0;
for(const [root, e] of cnt){ const nm = baseOf(root); if(/^[A-Z0-9]{2,}$/.test(nm) && lc(nm) !== lc((SYS[e.sys] && SYS[e.sys].es) || e.sys)){ codeRoots++; codeGames += e.n; } }
return codeRoots >= 3 && total > 0 && codeGames / total >= 0.6; // mostly device-style names: this IS a card, so keep them
}
const effScheme = () => { const s = S.cfg.folderNames || 'auto'; return s === 'auto' ? (S.deviceStyle ? 'keep' : 'es') : s; };
function computeRoots(){
const m = new Map(), base = romBase();
for(const g of S.games){ if(g.isBios || g.locked) continue; const r = systemRootOf(g.primary); if(!r) continue; if(!m.has(r.root)) m.set(r.root, { root: r.root, sys: r.sys, games: [] }); m.get(r.root).games.push(g); }
const list = [...m.values()];
if(effScheme() === 'keep'){
// keep every folder name. If one system is spread over several folders, offer (but do not preselect) merging into the biggest.
const main = new Map(); for(const r of list){ const cur = main.get(r.sys); if(!cur || r.games.length > cur.games.length) main.set(r.sys, r); }
return list.map(x => { const mn = main.get(x.sys), merge = mn !== x; return { ...x, to: merge ? mn.root : x.root, fine: !merge, min: merge ? 99 : 0, key: 'r:' + x.root }; }).sort((a, b) => natural(a.root, b.root));
}
const wanted = sys => (base ? base + '/' : '') + sysFolderName(sys);
const existing = new Map(); for(const r of list) if(lc(r.root) === lc(wanted(r.sys)) && !existing.has(r.sys)) existing.set(r.sys, r.root);
return list.map(x => { const to = existing.get(x.sys) || wanted(x.sys); return { ...x, to, fine: lc(x.root) === lc(to), min: 0, key: 'r:' + x.root }; }).sort((a, b) => natural(a.root, b.root));
}
function folderFor(sys){
const r = computeRoots().find(x => x.sys === sys); if(r) return r.to;
const base = romBase(); return (base ? base + '/' : '') + sysFolderName(sys);
}
function organizeCount(){
const roots = computeRoots().filter(r => !r.fine).length, loose = S.games.filter(g => !folderSystem(g.primary) && !g.isBios).length;
return { roots, loose, total: roots + loose };
}
function renderOrganize(){
const scheme = S.cfg.folderNames || 'auto', eff = effScheme(), roots = computeRoots(), base = romBase(), todo = roots.filter(r => !r.fine);
const explain = { auto: S.deviceStyle ? 'This looks like a handheld’s SD card (folders like FC, SFC, PS). Its folder names are kept so the device still finds its games.' : 'Short names used by EmulationStation, ES-DE, RetroPie, Batocera and most frontends.',
keep: 'Leaves every existing system folder name alone. Best for a device card. If one system is spread over several folders you can tick to merge them.',
onion: 'OnionOS codes such as FC, SFC, GBA and PS, which is what Miyoo Mini firmware looks for.', es: 'Short names used by EmulationStation, ES-DE, RetroPie, Batocera, OnionOS and most handheld firmware. Recommended. This page still shows the full system names while you browse.',
proper: 'Readable names such as “Sony PlayStation”. Easy to read in a file manager, but frontends that expect short names will not find them on their own.',
retroarch: 'The names RetroArch uses for its playlists and thumbnails, such as “Sony - PlayStation”. RetroArch itself does not require any folder naming.' }[scheme];
$('#cleanLead').textContent = (todo.length || C.stray.length) ? todo.length + (eff === 'keep' ? ' system folders can be merged, ' : ' system folders to rename or merge, ') + C.stray.length + ' loose games to place. Files are moved, never deleted.' : 'Every game is already inside a system folder with the right name.';
const counts = new Map(); S.games.filter(g => !g.isBios && g.system !== 'other').forEach(g => counts.set(g.system, (counts.get(g.system) || 0) + 1));
const lines = [(base || S.rootName) + '/']; (eff === 'keep' ? roots.map(r => [r.sys, r.games.length, baseOf(r.root)]) : [...counts].map(([id, n]) => [id, n, sysFolderName(id)])).slice(0, 6).forEach(([id, n, nm], i) => { lines.push(' ' + nm + '/ ' + n + ' games'); if(i === 0){ lines.push(' Game Name (USA).ext'); lines.push(' gamelist.xml'); lines.push(' media/images media/snaps media/videos'); } });
if(counts.size > 6) lines.push(' … ' + (counts.size - 6) + ' more systems');
const bySys = new Map(); roots.forEach(r => { if(!bySys.has(r.sys)) bySys.set(r.sys, []); bySys.get(r.sys).push(r); });
const spread = [...bySys].filter(([, l]) => l.length > 1).map(([s, l]) => (SYS[s] ? SYS[s].short : s) + ' (' + l.length + ' folders)');
const baseUI = eff === 'keep' ? '' : '
' +
'
Suggested: ' + esc(suggestedBase() || 'the top folder') + ', where most of your games already are.' + (spread.length ? ' These systems are spread over several folders and will be merged into one each: ' + esc(spread.slice(0, 12).join(', ')) + (spread.length > 12 ? ', …' : '') + '.' : '') + '
';
let h = baseUI + '
' +
'
' + esc(explain) + '
' + esc(lines.join('\n')) + '
';
h += '
System folders
' + (roots.map(r => r.fine ?
'
✓' + esc(r.root) + '/ ' + r.games.length + ' games · already named correctly
' :
'
' + esc(r.root) + '/ → ' + esc(r.to) + '/' + r.games.length + ' games, plus any artwork and gamelist inside
After the folders look right, save box art, screenshots, videos and a gamelist.xml into each system folder, so every system is self-contained. Missing pictures are fetched when the libretro library has them. An existing gamelist.xml is never overwritten.