Skip to content

Instantly share code, notes, and snippets.

@kampelmuehler
Last active February 23, 2026 18:34
Show Gist options
  • Select an option

  • Save kampelmuehler/2c4ee6c75516cd71b3f9bc20cd6400d9 to your computer and use it in GitHub Desktop.

Select an option

Save kampelmuehler/2c4ee6c75516cd71b3f9bc20cd6400d9 to your computer and use it in GitHub Desktop.
Clone Hero Library Sanitizer

Clone Hero Library Sanitizer

A web app to manage your Clone Hero song library.

Features

  • Browse songs grouped by artist (read from song.ini metadata)
  • Rename artists (updates all song.ini files for that artist)
  • Delete individual songs or entire artists
  • Sync folder names to match metadata (Artist - Song format)

Usage

  1. Open clone-hero-sanitizer.html in a Chromium based browser with File System Access API enabled.
  2. Click "Select Library Folder" and choose your Clone Hero songs folder
  3. Manage your library using the buttons in the UI

Notes

  • All operations work directly with your files - no undo function
  • Artist/song names are read from the artist and name fields in song.ini
  • Renaming an artist updates the metadata files but doesn't rename folders (use "Sync Folder Names" for that)

I sanitized my own library using Brave 1.86.142 on Chromium 144.0.7559.97.

Update 02/23/26: Support nested folder structures

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clone Hero Library Manager</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
h1 {
color: #1e3c72;
font-size: 2em;
margin-bottom: 10px;
}
.subtitle {
color: #666;
font-size: 0.95em;
}
.controls {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.controls-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
background: #2a5298;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
transition: background 0.3s;
}
.btn:hover {
background: #1e3c72;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-danger {
background: #dc3545;
}
.btn-danger:hover {
background: #c82333;
}
.btn-success {
background: #28a745;
}
.btn-success:hover {
background: #218838;
}
.btn-small {
padding: 6px 12px;
font-size: 0.9em;
}
.library-info {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
}
.library-info p {
color: #495057;
margin: 5px 0;
}
.artist-list {
display: grid;
gap: 20px;
}
.artist-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.2s;
}
.artist-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.artist-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.artist-name {
font-size: 1.5em;
font-weight: 600;
}
.artist-count {
background: rgba(255, 255, 255, 0.2);
padding: 5px 12px;
border-radius: 20px;
font-size: 0.9em;
}
.artist-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.song-list {
padding: 20px;
}
.song-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e9ecef;
transition: background 0.2s;
}
.song-item:last-child {
border-bottom: none;
}
.song-item:hover {
background: #f8f9fa;
}
.song-name {
color: #333;
flex: 1;
}
.song-folder {
color: #888;
font-size: 0.85em;
margin-top: 3px;
}
.song-path {
color: #aaa;
font-size: 0.8em;
margin-top: 2px;
font-style: italic;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 500px;
width: 90%;
}
.modal-content h2 {
color: #1e3c72;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 1em;
}
.form-group input:focus {
outline: none;
border-color: #2a5298;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.empty-state {
background: white;
padding: 60px 20px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.empty-state h2 {
color: #495057;
margin-bottom: 10px;
}
.empty-state p {
color: #6c757d;
}
.loading {
text-align: center;
padding: 40px;
color: white;
font-size: 1.2em;
}
.progress-bar {
background: white;
border-radius: 12px;
padding: 20px;
margin-top: 20px;
}
.progress-track {
background: #e9ecef;
height: 30px;
border-radius: 15px;
overflow: hidden;
position: relative;
}
.progress-fill {
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
height: 100%;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
}
.progress-status {
color: #ccc;
font-size: 0.85em;
margin-top: 10px;
}
.warning-message {
background: #fff3cd;
border: 1px solid #ffc107;
color: #856404;
padding: 12px;
border-radius: 6px;
margin-top: 15px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎸 Clone Hero Library Manager</h1>
<p class="subtitle">Manage your Clone Hero song library with ease</p>
</header>
<div class="controls">
<div class="controls-row">
<button id="selectFolder" class="btn">Select Library Folder</button>
<button id="syncMetadata" class="btn btn-success" style="display: none;">Sync Folder Names from Metadata</button>
</div>
<div id="libraryInfo" class="library-info" style="display: none;">
<p><strong>Library:</strong> <span id="libraryPath"></span></p>
<p><strong>Artists:</strong> <span id="artistCount"></span> | <strong>Songs:</strong> <span id="songCount"></span></p>
</div>
</div>
<div id="content">
<div class="empty-state">
<h2>Welcome!</h2>
<p>Select your Clone Hero library folder to get started</p>
</div>
</div>
</div>
<!-- Rename Artist Modal -->
<div id="renameModal" class="modal">
<div class="modal-content">
<h2>Rename Artist</h2>
<div class="form-group">
<label for="newArtistName">New Artist Name</label>
<input type="text" id="newArtistName" placeholder="Enter new artist name">
</div>
<p class="warning-message">This will update all song.ini files for this artist. Folder names are not changed here (use Sync to rename folders).</p>
<div class="modal-actions">
<button class="btn" onclick="closeRenameModal()">Cancel</button>
<button class="btn btn-success" onclick="performRename()">Rename</button>
</div>
</div>
</div>
<script>
let libraryHandle = null;
// libraryData[artist] = [ { name, folderName, parentHandle, dirHandle }, ... ]
let libraryData = {};
let currentRenameArtist = null;
document.getElementById('selectFolder').addEventListener('click', selectLibraryFolder);
document.getElementById('syncMetadata').addEventListener('click', syncMetadataToFolders);
async function selectLibraryFolder() {
try {
libraryHandle = await window.showDirectoryPicker();
await loadLibrary();
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error selecting folder:', err);
alert('Error selecting folder: ' + err.message);
}
}
}
async function parseIniFile(fileContent) {
const ini = {};
let currentSection = null;
const lines = fileContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue;
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1].toLowerCase();
ini[currentSection] = ini[currentSection] || {};
continue;
}
const kvMatch = trimmed.match(/^([^=]+)=(.*)$/);
if (kvMatch && currentSection) {
const key = kvMatch[1].trim().toLowerCase();
const value = kvMatch[2].trim();
ini[currentSection][key] = value;
}
}
return ini;
}
/**
* Check if a directory handle contains a song.ini file.
* Returns parsed metadata or null if not a song folder.
*/
async function readSongIni(dirHandle) {
try {
const fileHandle = await dirHandle.getFileHandle('song.ini');
const file = await fileHandle.getFile();
const content = await file.text();
const ini = await parseIniFile(content);
return {
artist: ini.song?.artist || 'Unknown Artist',
name: ini.song?.name || 'Unknown Song'
};
} catch (err) {
return null;
}
}
/**
* Recursively walk a directory tree.
* For each directory that contains a song.ini, record it as a song.
* Directories that are song folders are NOT descended into further.
*
* @param {FileSystemDirectoryHandle} dirHandle - current directory to scan
* @param {FileSystemDirectoryHandle} parentHandle - immediate parent (for removeEntry)
* @param {Function} onSongFound - callback(songEntry) where songEntry = { artist, name, folderName, parentHandle, dirHandle }
* @param {Function} onDirScanned - callback() called after each directory is processed (for progress tracking)
*/
async function walkDirectory(dirHandle, parentHandle, onSongFound, onDirScanned) {
// Check if this directory is a song folder
const metadata = await readSongIni(dirHandle);
if (metadata) {
onSongFound({
artist: metadata.artist,
name: metadata.name,
folderName: dirHandle.name,
parentHandle: parentHandle,
dirHandle: dirHandle
});
if (onDirScanned) onDirScanned();
return; // Don't recurse into song folders
}
// Otherwise, recurse into subdirectories
for await (const entry of dirHandle.values()) {
if (entry.kind === 'directory') {
await walkDirectory(entry, dirHandle, onSongFound, onDirScanned);
}
}
if (onDirScanned) onDirScanned();
}
async function loadLibrary() {
document.getElementById('content').innerHTML = `
<div class="loading">
<p>Scanning library (this may take a moment for large libraries)...</p>
<div class="progress-bar">
<div class="progress-track">
<div class="progress-fill" id="progressFill" style="width: 5%">Scanning...</div>
</div>
<div class="progress-status" id="progressStatus">Looking for songs...</div>
</div>
</div>
`;
libraryData = {};
let songsFound = 0;
try {
const updateStatus = (label) => {
const el = document.getElementById('progressStatus');
if (el) el.textContent = label;
};
const updateFill = (label) => {
const el = document.getElementById('progressFill');
if (el) el.textContent = label;
};
await walkDirectory(
libraryHandle,
null, // root has no parent within the scope we care about
(songEntry) => {
// Song found callback
const { artist, name, folderName, parentHandle, dirHandle } = songEntry;
if (!libraryData[artist]) libraryData[artist] = [];
libraryData[artist].push({ name, folderName, parentHandle, dirHandle });
songsFound++;
updateStatus(`Found ${songsFound} song${songsFound !== 1 ? 's' : ''}...`);
updateFill(`${songsFound} songs`);
},
null // no per-dir progress callback needed; song count is enough
);
const progressFill = document.getElementById('progressFill');
if (progressFill) { progressFill.style.width = '100%'; progressFill.textContent = 'Done!'; }
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
document.getElementById('syncMetadata').style.display = 'inline-block';
} catch (err) {
alert('Error loading library: ' + err.message);
document.getElementById('content').innerHTML = `<div class="empty-state"><h2>Error Loading Library</h2><p>${escapeHtml(err.message)}</p></div>`;
}
}
function displayLibrary(artists) {
if (artists.length === 0) {
document.getElementById('content').innerHTML = '<div class="empty-state"><h2>No Songs Found</h2><p>No folders with valid song.ini files found in this directory</p></div>';
return;
}
let html = '<div class="artist-list">';
for (const artist of artists) {
const songs = libraryData[artist];
songs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
html += `
<div class="artist-card">
<div class="artist-header">
<div>
<div class="artist-name">${escapeHtml(artist)}</div>
<div class="artist-actions">
<button class="btn btn-small btn-rename" data-artist="${escapeHtml(artist)}">Rename Artist</button>
<button class="btn btn-danger btn-small btn-delete-artist" data-artist="${escapeHtml(artist)}">Delete All Songs</button>
</div>
</div>
<div class="artist-count">${songs.length} song${songs.length !== 1 ? 's' : ''}</div>
</div>
<div class="song-list">
`;
for (const song of songs) {
// Build a display path relative to library root if nested
const isNested = song.parentHandle && song.parentHandle.name !== libraryHandle.name;
const pathLabel = isNested
? `📁 ${escapeHtml(song.folderName)} <span style="color:#bbb;">— in subfolder</span>`
: `📁 ${escapeHtml(song.folderName)}`;
html += `
<div class="song-item">
<div>
<div class="song-name">${escapeHtml(song.name)}</div>
<div class="song-folder">${pathLabel}</div>
</div>
<button class="btn btn-danger btn-small btn-delete-song" data-artist="${escapeHtml(artist)}" data-folder="${escapeHtml(song.folderName)}">Delete</button>
</div>
`;
}
html += `</div></div>`;
}
html += '</div>';
document.getElementById('content').innerHTML = html;
document.getElementById('content').addEventListener('click', handleContentClick);
}
function handleContentClick(e) {
const target = e.target;
if (target.classList.contains('btn-rename')) {
renameArtist(target.dataset.artist);
} else if (target.classList.contains('btn-delete-artist')) {
deleteArtist(target.dataset.artist);
} else if (target.classList.contains('btn-delete-song')) {
deleteSong(target.dataset.artist, target.dataset.folder);
}
}
function updateLibraryInfo() {
document.getElementById('libraryInfo').style.display = 'block';
document.getElementById('libraryPath').textContent = libraryHandle.name;
document.getElementById('artistCount').textContent = Object.keys(libraryData).length;
let totalSongs = 0;
for (const artist in libraryData) totalSongs += libraryData[artist].length;
document.getElementById('songCount').textContent = totalSongs;
}
function findSong(artist, folderName) {
return libraryData[artist]?.find(s => s.folderName === folderName) || null;
}
async function deleteSong(artist, folderName) {
if (!confirm(`Are you sure you want to delete "${folderName}"?`)) return;
const scrollPosition = window.scrollY;
try {
const song = findSong(artist, folderName);
if (!song) throw new Error('Song not found in library data');
// Use the song's direct parent handle for removal
const parentHandle = song.parentHandle || libraryHandle;
await parentHandle.removeEntry(folderName, { recursive: true });
libraryData[artist] = libraryData[artist].filter(s => s.folderName !== folderName);
if (libraryData[artist].length === 0) delete libraryData[artist];
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
setTimeout(() => window.scrollTo(0, scrollPosition), 50);
} catch (err) {
alert('Error deleting song: ' + err.message);
}
}
async function deleteArtist(artist) {
const songs = libraryData[artist];
if (!confirm(`Are you sure you want to delete all ${songs.length} song(s) by ${artist}?`)) return;
const scrollPosition = window.scrollY;
try {
for (const song of songs) {
const parentHandle = song.parentHandle || libraryHandle;
await parentHandle.removeEntry(song.folderName, { recursive: true });
}
delete libraryData[artist];
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
setTimeout(() => window.scrollTo(0, scrollPosition), 50);
} catch (err) {
alert('Error deleting artist: ' + err.message);
}
}
function renameArtist(artist) {
currentRenameArtist = artist;
document.getElementById('newArtistName').value = artist;
document.getElementById('renameModal').classList.add('active');
document.getElementById('newArtistName').focus();
document.getElementById('newArtistName').select();
}
function closeRenameModal() {
document.getElementById('renameModal').classList.remove('active');
currentRenameArtist = null;
}
async function updateSongIni(dirHandle, newArtist) {
const fileHandle = await dirHandle.getFileHandle('song.ini', { create: false });
const file = await fileHandle.getFile();
const content = await file.text();
const lines = content.split('\n');
const updatedLines = lines.map(line => {
const trimmed = line.trim();
const lowerTrimmed = trimmed.toLowerCase();
if (lowerTrimmed.startsWith('artist =') || lowerTrimmed.startsWith('artist=')) {
const hasSpace = trimmed.includes('artist =');
return hasSpace ? `artist = ${newArtist}` : `artist=${newArtist}`;
}
return line;
});
const writable = await fileHandle.createWritable();
await writable.write(updatedLines.join('\n'));
await writable.close();
}
async function performRename() {
const newArtistName = document.getElementById('newArtistName').value.trim();
if (!newArtistName) { alert('Please enter a new artist name'); return; }
if (newArtistName === currentRenameArtist) { closeRenameModal(); return; }
const songs = libraryData[currentRenameArtist];
const oldArtistName = currentRenameArtist;
closeRenameModal();
const scrollPosition = window.scrollY;
document.getElementById('content').innerHTML = `
<div class="loading">
<p>Renaming artist...</p>
<div class="progress-bar">
<div class="progress-track">
<div class="progress-fill" id="renameProgressFill" style="width: 0%">0%</div>
</div>
</div>
</div>
`;
try {
for (let i = 0; i < songs.length; i++) {
const song = songs[i];
await updateSongIni(song.dirHandle, newArtistName);
const percent = Math.round(((i + 1) / songs.length) * 100);
const progressFill = document.getElementById('renameProgressFill');
if (progressFill) { progressFill.style.width = percent + '%'; progressFill.textContent = percent + '%'; }
}
if (libraryData[newArtistName]) {
libraryData[newArtistName] = [...libraryData[newArtistName], ...songs];
} else {
libraryData[newArtistName] = songs;
}
delete libraryData[oldArtistName];
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
setTimeout(() => window.scrollTo(0, scrollPosition), 100);
} catch (err) {
alert('Error renaming artist: ' + err.message);
await loadLibrary();
setTimeout(() => window.scrollTo(0, scrollPosition), 100);
}
}
async function syncMetadataToFolders() {
if (!confirm('This will rename all song folders to match the metadata in song.ini files (format: "Artist - Song Name"). Folders are renamed within their current subfolder. This cannot be undone. Continue?')) return;
let totalSongs = 0;
for (const artist in libraryData) totalSongs += libraryData[artist].length;
document.getElementById('content').innerHTML = `
<div class="loading">
<p>Syncing folder names from metadata...</p>
<div class="progress-bar">
<div class="progress-track">
<div class="progress-fill" id="syncProgressFill" style="width: 0%">0%</div>
</div>
</div>
</div>
`;
try {
let processed = 0;
for (const artist in libraryData) {
const songs = libraryData[artist];
for (const song of songs) {
const expectedFolderName = `${artist} - ${song.name}`;
if (song.folderName !== expectedFolderName) {
try {
const parentHandle = song.parentHandle || libraryHandle;
// Create new directory in the same parent
const newDirHandle = await parentHandle.getDirectoryHandle(expectedFolderName, { create: true });
// Copy all files from old to new
await copyDirectory(song.dirHandle, newDirHandle);
// Delete old directory
await parentHandle.removeEntry(song.folderName, { recursive: true });
// Update local data
song.folderName = expectedFolderName;
song.dirHandle = newDirHandle;
} catch (err) {
console.error(`Error renaming ${song.folderName}:`, err);
}
}
processed++;
const percent = Math.round((processed / totalSongs) * 100);
const progressFill = document.getElementById('syncProgressFill');
if (progressFill) { progressFill.style.width = percent + '%'; progressFill.textContent = percent + '%'; }
}
}
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
alert('Folder names synced successfully!');
} catch (err) {
alert('Error syncing folder names: ' + err.message);
await loadLibrary();
}
}
async function copyDirectory(sourceHandle, destHandle) {
for await (const entry of sourceHandle.values()) {
if (entry.kind === 'file') {
const file = await entry.getFile();
const newFileHandle = await destHandle.getFileHandle(entry.name, { create: true });
const writable = await newFileHandle.createWritable();
await writable.write(file);
await writable.close();
} else if (entry.kind === 'directory') {
const newDirHandle = await destHandle.getDirectoryHandle(entry.name, { create: true });
await copyDirectory(entry, newDirHandle);
}
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.getElementById('newArtistName').addEventListener('keypress', (e) => {
if (e.key === 'Enter') performRename();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment