|
<!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> |