Skip to content

Instantly share code, notes, and snippets.

@kampelmuehler
Last active January 28, 2026 09:54
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.

<!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;
}
.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;
}
.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 rename all song folders and update all song.ini files for this artist.</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;
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();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) {
continue;
}
// Check for section header (case-insensitive)
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1].toLowerCase();
ini[currentSection] = ini[currentSection] || {};
continue;
}
// Parse key-value pairs (case-insensitive keys)
const kvMatch = trimmed.match(/^([^=]+)=(.*)$/);
if (kvMatch && currentSection) {
const key = kvMatch[1].trim().toLowerCase();
const value = kvMatch[2].trim();
ini[currentSection][key] = value;
}
}
return ini;
}
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) {
// If song.ini doesn't exist, try to parse from folder name
return null;
}
}
async function loadLibrary() {
document.getElementById('content').innerHTML = '<div class="loading">Loading library...</div>';
libraryData = {};
try {
let processed = 0;
let total = 0;
// First count total entries
for await (const entry of libraryHandle.values()) {
if (entry.kind === 'directory') {
total++;
}
}
// Show progress
document.getElementById('content').innerHTML = `
<div class="loading">
<p>Scanning library...</p>
<div class="progress-bar">
<div class="progress-track">
<div class="progress-fill" id="progressFill" style="width: 0%">0%</div>
</div>
</div>
</div>
`;
// Process each directory
for await (const entry of libraryHandle.values()) {
if (entry.kind === 'directory') {
const metadata = await readSongIni(entry);
if (metadata) {
const artist = metadata.artist;
const songName = metadata.name;
if (!libraryData[artist]) {
libraryData[artist] = [];
}
libraryData[artist].push({
name: songName,
folderName: entry.name,
dirHandle: entry
});
}
processed++;
const percent = Math.round((processed / total) * 100);
const progressFill = document.getElementById('progressFill');
if (progressFill) {
progressFill.style.width = percent + '%';
progressFill.textContent = percent + '%';
}
}
}
// Sort artists alphabetically
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>' + 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) {
html += `
<div class="song-item">
<div>
<div class="song-name">${escapeHtml(song.name)}</div>
<div class="song-folder">📁 ${escapeHtml(song.folderName)}</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;
// Add event listeners using event delegation
document.getElementById('content').addEventListener('click', handleContentClick);
}
function handleContentClick(e) {
const target = e.target;
if (target.classList.contains('btn-rename')) {
const artist = target.dataset.artist;
renameArtist(artist);
} else if (target.classList.contains('btn-delete-artist')) {
const artist = target.dataset.artist;
deleteArtist(artist);
} else if (target.classList.contains('btn-delete-song')) {
const artist = target.dataset.artist;
const folderName = target.dataset.folder;
deleteSong(artist, folderName);
}
}
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;
}
async function deleteSong(artist, folderName) {
if (!confirm(`Are you sure you want to delete "${folderName}"?`)) {
return;
}
// Save scroll position
const scrollPosition = window.scrollY;
try {
await libraryHandle.removeEntry(folderName, { recursive: true });
// Update local data
libraryData[artist] = libraryData[artist].filter(s => s.folderName !== folderName);
if (libraryData[artist].length === 0) {
delete libraryData[artist];
}
// Refresh display
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
// Restore scroll position
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;
}
// Save scroll position
const scrollPosition = window.scrollY;
try {
for (const song of songs) {
await libraryHandle.removeEntry(song.folderName, { recursive: true });
}
// Update local data
delete libraryData[artist];
// Refresh display
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
// Restore scroll position
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) {
try {
const fileHandle = await dirHandle.getFileHandle('song.ini', { create: false });
const file = await fileHandle.getFile();
const content = await file.text();
// Replace artist line in the INI file (case-insensitive)
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=')) {
// Preserve original spacing style
const hasSpace = trimmed.includes('artist =');
return hasSpace ? `artist = ${newArtist}` : `artist=${newArtist}`;
}
return line;
});
const updatedContent = updatedLines.join('\n');
// Write back to file
const writable = await fileHandle.createWritable();
await writable.write(updatedContent);
await writable.close();
} catch (err) {
console.error('Error updating song.ini:', err);
throw err;
}
}
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();
// Save scroll position
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 {
// Update all song.ini files for this artist
for (let i = 0; i < songs.length; i++) {
const song = songs[i];
const dirHandle = await libraryHandle.getDirectoryHandle(song.folderName);
await updateSongIni(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 + '%';
}
}
// Update local data - merge with existing artist if it exists
if (libraryData[newArtistName]) {
libraryData[newArtistName] = [...libraryData[newArtistName], ...songs];
} else {
libraryData[newArtistName] = songs;
}
delete libraryData[oldArtistName];
// Refresh display
const sortedArtists = Object.keys(libraryData).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
displayLibrary(sortedArtists);
updateLibraryInfo();
// Restore scroll position after a brief delay to allow DOM to render
setTimeout(() => {
window.scrollTo(0, scrollPosition);
}, 100);
} catch (err) {
alert('Error renaming artist: ' + err.message);
// Reload library to ensure consistency
await loadLibrary();
// Restore scroll position
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. This operation 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}`;
// Only rename if the folder name doesn't match
if (song.folderName !== expectedFolderName) {
try {
// Get the old directory handle
const oldDirHandle = await libraryHandle.getDirectoryHandle(song.folderName);
// Create new directory
const newDirHandle = await libraryHandle.getDirectoryHandle(expectedFolderName, { create: true });
// Copy all files from old to new
await copyDirectory(oldDirHandle, newDirHandle);
// Delete old directory
await libraryHandle.removeEntry(song.folderName, { recursive: true });
// Update local data
song.folderName = expectedFolderName;
} catch (err) {
console.error(`Error renaming ${song.folderName}:`, err);
// Continue with other songs even if one fails
}
}
processed++;
const percent = Math.round((processed / totalSongs) * 100);
const progressFill = document.getElementById('syncProgressFill');
if (progressFill) {
progressFill.style.width = percent + '%';
progressFill.textContent = percent + '%';
}
}
}
// Refresh display
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;
}
// Allow Enter key to confirm rename
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