Add album file reorganization to Enhanced Library Manager

pull/253/head
Broque Thomas 2 months ago
parent d4eadef374
commit 57a8bdd107

@ -9829,6 +9829,371 @@ def get_write_tags_batch_status():
return jsonify(state)
# ── Reorganize Album Files endpoint ──
_reorganize_state = {
'status': 'idle',
'total': 0,
'processed': 0,
'moved': 0,
'skipped': 0,
'failed': 0,
'current_track': '',
'errors': [],
}
_reorganize_lock = threading.Lock()
@app.route('/api/library/album/<album_id>/reorganize/preview', methods=['POST'])
def reorganize_album_preview(album_id):
"""Preview file reorganization for an album — returns current vs proposed paths without moving anything."""
try:
database = get_database()
data = request.get_json() or {}
template = data.get('template', '').strip()
if not template:
return jsonify({"success": False, "error": "Template is required"}), 400
conn = database._get_connection()
cursor = conn.cursor()
# Get album + artist info
cursor.execute("""
SELECT al.*, a.name as artist_name
FROM albums al
JOIN artists a ON al.artist_id = a.id
WHERE al.id = ?
""", (str(album_id),))
album_row = cursor.fetchone()
if not album_row:
return jsonify({"success": False, "error": "Album not found"}), 404
album_data = dict(album_row)
# Get all tracks for this album
cursor.execute("""
SELECT t.*, a.name as artist_name
FROM tracks t
JOIN artists a ON t.artist_id = a.id
WHERE t.album_id = ?
ORDER BY t.track_number
""", (str(album_id),))
tracks = [dict(r) for r in cursor.fetchall()]
if not tracks:
return jsonify({"success": False, "error": "No tracks found for this album"}), 404
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
preview_items = []
for track in tracks:
file_path = track.get('file_path')
resolved = _resolve_library_file_path(file_path) if file_path else None
# Read disc_number from file tags if available
disc_number = 1
if resolved:
try:
from core.tag_writer import read_file_tags
file_tags = read_file_tags(resolved)
disc_number = file_tags.get('disc_number') or 1
except Exception:
pass
# Get file extension from current path
file_ext = os.path.splitext(resolved or file_path or '.mp3')[1]
# Detect quality using the same format as the download pipeline
quality = _get_audio_quality_string(resolved) if resolved else ''
# Build context for template
year_val = album_data.get('year') or ''
context = {
'artist': track.get('artist_name') or 'Unknown Artist',
'albumartist': album_data.get('artist_name') or track.get('artist_name') or 'Unknown Artist',
'album': album_data.get('title') or 'Unknown Album',
'title': track.get('title') or 'Unknown Track',
'track_number': track.get('track_number') or 1,
'disc_number': disc_number,
'year': year_val,
'quality': quality,
}
# Build new path using the template
folder_path, filename = _get_file_path_from_template_raw(template, context)
new_relative = os.path.join(folder_path, f"{filename}{file_ext}") if folder_path else f"{filename}{file_ext}"
new_full = os.path.join(transfer_dir, new_relative)
# Current path relative to transfer dir for display
current_display = file_path or 'No file'
if resolved and transfer_dir and resolved.startswith(transfer_dir):
current_display = resolved[len(transfer_dir):].lstrip(os.sep).lstrip('/')
same = resolved and os.path.normpath(resolved) == os.path.normpath(new_full)
preview_items.append({
'track_id': track['id'],
'title': track.get('title', ''),
'track_number': track.get('track_number', 0),
'current_path': current_display,
'new_path': new_relative,
'new_full_normalized': os.path.normpath(new_full) if resolved else None,
'file_exists': resolved is not None,
'unchanged': same,
'collision': False,
})
# Detect collisions: multiple tracks mapping to the same destination
seen_paths = {}
for item in preview_items:
norm = item.get('new_full_normalized')
if not norm or not item['file_exists'] or item['unchanged']:
continue
if norm in seen_paths:
item['collision'] = True
# Also mark the first one that claimed this path
seen_paths[norm]['collision'] = True
else:
seen_paths[norm] = item
# Remove internal field from response
for item in preview_items:
item.pop('new_full_normalized', None)
return jsonify({
"success": True,
"album": album_data.get('title', ''),
"artist": album_data.get('artist_name', ''),
"tracks": preview_items,
"transfer_dir": transfer_dir,
})
except Exception as e:
logger.error(f"Reorganize preview error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/album/<album_id>/reorganize', methods=['POST'])
def reorganize_album_files(album_id):
"""Move album files to new paths based on the provided template."""
try:
data = request.get_json() or {}
template = data.get('template', '').strip()
if not template:
return jsonify({"success": False, "error": "Template is required"}), 400
# Atomic check-and-set to prevent concurrent reorganizations
with _reorganize_lock:
if _reorganize_state['status'] == 'running':
return jsonify({"success": False, "error": "A reorganization is already in progress"}), 409
_reorganize_state['status'] = 'running'
database = get_database()
conn = database._get_connection()
cursor = conn.cursor()
# Get album + artist info
cursor.execute("""
SELECT al.*, a.name as artist_name
FROM albums al
JOIN artists a ON al.artist_id = a.id
WHERE al.id = ?
""", (str(album_id),))
album_row = cursor.fetchone()
if not album_row:
with _reorganize_lock:
_reorganize_state['status'] = 'idle'
return jsonify({"success": False, "error": "Album not found"}), 404
album_data = dict(album_row)
# Get all tracks
cursor.execute("""
SELECT t.*, a.name as artist_name
FROM tracks t
JOIN artists a ON t.artist_id = a.id
WHERE t.album_id = ?
ORDER BY t.track_number
""", (str(album_id),))
tracks = [dict(r) for r in cursor.fetchall()]
if not tracks:
with _reorganize_lock:
_reorganize_state['status'] = 'idle'
return jsonify({"success": False, "error": "No tracks found"}), 404
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
# Initialize state (already set to 'running' above)
with _reorganize_lock:
_reorganize_state.update({
'total': len(tracks),
'processed': 0,
'moved': 0,
'skipped': 0,
'failed': 0,
'current_track': '',
'errors': [],
})
def _run_reorganize():
bg_conn = None
try:
# Single DB connection for the background thread
bg_db = get_database()
bg_conn = bg_db._get_connection()
# Pre-compute all destination paths to detect collisions
dest_paths = {} # normalized_new_path -> track_id
for track in tracks:
file_path = track.get('file_path')
resolved = _resolve_library_file_path(file_path) if file_path else None
if not resolved:
continue
disc_number = 1
try:
from core.tag_writer import read_file_tags
file_tags = read_file_tags(resolved)
disc_number = file_tags.get('disc_number') or 1
except Exception:
pass
file_ext = os.path.splitext(resolved)[1]
quality = _get_audio_quality_string(resolved)
year_val = album_data.get('year') or ''
context = {
'artist': track.get('artist_name') or 'Unknown Artist',
'albumartist': album_data.get('artist_name') or track.get('artist_name') or 'Unknown Artist',
'album': album_data.get('title') or 'Unknown Album',
'title': track.get('title') or 'Unknown Track',
'track_number': track.get('track_number') or 1,
'disc_number': disc_number,
'year': year_val,
'quality': quality,
}
folder_path, filename = _get_file_path_from_template_raw(template, context)
new_relative = os.path.join(folder_path, f"{filename}{file_ext}") if folder_path else f"{filename}{file_ext}"
new_full = os.path.join(transfer_dir, new_relative)
norm_new = os.path.normpath(new_full)
# Check for collision: two tracks mapping to same destination
if norm_new in dest_paths and dest_paths[norm_new] != str(track['id']):
# Mark as collision so the move pass skips it
track['_collision'] = True
with _reorganize_lock:
_reorganize_state['failed'] += 1
_reorganize_state['processed'] += 1
_reorganize_state['errors'].append({
'track_id': track['id'],
'title': track.get('title', 'Unknown'),
'error': f"Path collision with another track — add $track or $disc to template"
})
continue
dest_paths[norm_new] = str(track['id'])
# Store computed info on the track dict for the move pass
track['_resolved'] = resolved
track['_new_full'] = new_full
track['_disc_number'] = disc_number
# Now do the actual moves
for track in tracks:
resolved = track.get('_resolved')
new_full = track.get('_new_full')
track_title = track.get('title', 'Unknown')
with _reorganize_lock:
_reorganize_state['current_track'] = track_title
# Skip tracks already handled (collision or file not found)
if track.get('_collision'):
continue
if not resolved or not new_full:
# File not found — only count if not already handled in pre-computation
if '_resolved' not in track:
with _reorganize_lock:
_reorganize_state['skipped'] += 1
_reorganize_state['processed'] += 1
_reorganize_state['errors'].append({
'track_id': track['id'],
'title': track_title,
'error': 'File not found on disk'
})
continue
# Skip if already at target
if os.path.normpath(resolved) == os.path.normpath(new_full):
with _reorganize_lock:
_reorganize_state['skipped'] += 1
_reorganize_state['processed'] += 1
continue
try:
# Move file
_safe_move_file(resolved, new_full)
# Update DB file_path
bg_cursor = bg_conn.cursor()
bg_cursor.execute(
"UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_full, str(track['id']))
)
bg_conn.commit()
# Clean up empty directories left behind
_cleanup_empty_directories(transfer_dir, resolved)
with _reorganize_lock:
_reorganize_state['moved'] += 1
_reorganize_state['processed'] += 1
except Exception as move_err:
logger.error(f"Reorganize move error for {track_title}: {move_err}")
with _reorganize_lock:
_reorganize_state['failed'] += 1
_reorganize_state['processed'] += 1
_reorganize_state['errors'].append({
'track_id': track['id'],
'title': track_title,
'error': str(move_err)
})
except Exception as e:
logger.error(f"Reorganize background error: {e}")
finally:
if bg_conn:
try:
bg_conn.close()
except Exception:
pass
with _reorganize_lock:
_reorganize_state['status'] = 'done'
_reorganize_state['current_track'] = ''
thread = threading.Thread(target=_run_reorganize, daemon=True, name="ReorganizeAlbum")
thread.start()
return jsonify({"success": True, "message": "Reorganization started", "total": len(tracks)})
except Exception as e:
logger.error(f"Reorganize error: {e}")
with _reorganize_lock:
_reorganize_state['status'] = 'idle'
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/album/reorganize/status', methods=['GET'])
def get_reorganize_status():
"""Poll the status of a running reorganization."""
with _reorganize_lock:
state = dict(_reorganize_state)
state['errors'] = list(_reorganize_state['errors'])
return jsonify(state)
def _sync_tracks_to_server(track_rows, server_type):
"""Sync metadata for tracks to the active media server after writing file tags.
@ -12664,6 +13029,67 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext):
new_filename = f"{final_track_name_sanitized}{file_ext}"
return os.path.join(single_dir, new_filename), True
def _get_file_path_from_template_raw(template: str, context: dict) -> tuple:
"""
Build file path using a user-provided template string directly.
Unlike _get_file_path_from_template, this bypasses config lookup and the
file_organization.enabled check used by the reorganize feature where the
user supplies the template explicitly.
Args:
template: Template string like "$artist/$album/$track - $title"
context: Dict with all track/album metadata
Returns:
(folder_path, filename_base) tuple no file extension included
"""
import re
full_path = _apply_path_template(template, context)
quality_value = context.get('quality', '')
disc_value = f"{context.get('disc_number', 1):02d}"
path_parts = full_path.split('/')
if len(path_parts) > 1:
folder_parts = path_parts[:-1]
filename_base = path_parts[-1]
cleaned_folders = []
for part in folder_parts:
part = part.replace('$quality', '')
part = part.replace('$disc', '')
part = re.sub(r'\s*\[\s*\]', '', part)
part = re.sub(r'\s*\(\s*\)', '', part)
part = re.sub(r'\s*\{\s*\}', '', part)
part = re.sub(r'\s*-\s*$', '', part)
part = re.sub(r'^\s*-\s*', '', part)
part = re.sub(r'\s+', ' ', part).strip()
if part:
cleaned_folders.append(part)
filename_base = filename_base.replace('$quality', quality_value)
filename_base = filename_base.replace('$disc', disc_value)
filename_base = re.sub(r'\s*\[\s*\]', '', filename_base)
filename_base = re.sub(r'\s*\(\s*\)', '', filename_base)
filename_base = re.sub(r'\s*\{\s*\}', '', filename_base)
filename_base = re.sub(r'\s*-\s*$', '', filename_base)
filename_base = re.sub(r'\s+', ' ', filename_base).strip()
sanitized_folders = [_sanitize_filename(part) for part in cleaned_folders]
folder_path = os.path.join(*sanitized_folders) if sanitized_folders else ''
return folder_path, _sanitize_filename(filename_base)
else:
full_path = full_path.replace('$quality', quality_value)
full_path = full_path.replace('$disc', disc_value)
full_path = re.sub(r'\s*\[\s*\]', '', full_path)
full_path = re.sub(r'\s*\(\s*\)', '', full_path)
full_path = re.sub(r'\s*\{\s*\}', '', full_path)
full_path = re.sub(r'\s*-\s*$', '', full_path)
full_path = re.sub(r'\s+', ' ', full_path).strip()
return '', _sanitize_filename(full_path)
def _get_audio_quality_string(file_path):
"""
Read audio file and return a quality descriptor string.

@ -2641,6 +2641,23 @@
</div>
</div>
<!-- Reorganize Album Modal -->
<div class="modal-overlay hidden" id="reorganize-overlay">
<div class="enhanced-bulk-modal reorganize-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="reorganize-modal-title">Reorganize Album</h3>
<button class="enhanced-bulk-modal-close" onclick="closeReorganizeModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="reorganize-modal-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer" id="reorganize-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeReorganizeModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="reorganize-apply-btn" onclick="executeReorganize()" disabled>Apply</button>
</div>
</div>
</div>
<!-- Discover Page -->
<div class="page" id="discover-page">
<div class="discover-container">

@ -35966,6 +35966,14 @@ function renderExpandedAlbumHeader(album) {
writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); };
enrichRow.appendChild(writeTagsBtn);
// Reorganize button
const reorganizeBtn = document.createElement('button');
reorganizeBtn.className = 'enhanced-reorganize-album-btn';
reorganizeBtn.innerHTML = '&#128193; Reorganize';
reorganizeBtn.title = 'Reorganize album files using a custom path template';
reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); };
enrichRow.appendChild(reorganizeBtn);
// Delete album button
const deleteAlbumBtn = document.createElement('button');
deleteAlbumBtn.className = 'enhanced-delete-album-btn';
@ -37499,6 +37507,255 @@ function _pollBatchWriteTagsStatus() {
_batchWriteTagsPollTimer = setTimeout(poll, 800);
}
// ── Reorganize Album Files ──
let _reorganizeAlbumId = null;
let _reorganizePollTimer = null;
async function showReorganizeModal(albumId) {
_reorganizeAlbumId = albumId;
const overlay = document.getElementById('reorganize-overlay');
const body = document.getElementById('reorganize-modal-body');
const title = document.getElementById('reorganize-modal-title');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!overlay || !body) return;
// Find album data from enhanced view state
let albumData = null;
let artistName = '';
if (artistDetailPageState.enhancedData) {
artistName = artistDetailPageState.enhancedData.artist.name || '';
const allAlbums = artistDetailPageState.enhancedData.albums || [];
albumData = allAlbums.find(a => String(a.id) === String(albumId));
}
title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`;
if (applyBtn) applyBtn.disabled = true;
// Build modal content
const variables = [
{ var: '$artist', desc: 'Track artist', example: artistName || 'Artist' },
{ var: '$albumartist', desc: 'Album artist', example: artistName || 'Album Artist' },
{ var: '$artistletter', desc: 'First letter of artist', example: (artistName || 'A')[0].toUpperCase() },
{ var: '$album', desc: 'Album title', example: albumData ? albumData.title : 'Album' },
{ var: '$title', desc: 'Track title', example: 'Track Name' },
{ var: '$track', desc: 'Track number (zero-padded)', example: '01' },
{ var: '$disc', desc: 'Disc number (filename only)', example: '01' },
{ var: '$year', desc: 'Release year', example: albumData && albumData.year ? String(albumData.year) : '2024' },
{ var: '$quality', desc: 'Audio quality (filename only)', example: 'FLAC 16bit/44kHz' },
];
let html = '<div class="reorganize-content">';
// Template input
html += '<div class="reorganize-template-section">';
html += '<label class="reorganize-label">Path Template</label>';
html += '<div class="reorganize-template-hint">Use <code>/</code> to separate folders. The last segment becomes the filename.</div>';
html += '<input type="text" id="reorganize-template-input" class="reorganize-template-input" ';
html += 'value="$albumartist/$albumartist - $album/$track - $title" ';
html += 'placeholder="$albumartist/$album/$track - $title" spellcheck="false">';
html += '</div>';
// Variables reference
html += '<div class="reorganize-variables">';
html += '<label class="reorganize-label">Available Variables</label>';
html += '<div class="reorganize-var-grid">';
variables.forEach(v => {
html += `<div class="reorganize-var-chip" onclick="insertReorganizeVar('${v.var}')" title="${escapeHtml(v.desc)} — e.g. ${escapeHtml(v.example)}">`;
html += `<code>${v.var}</code><span class="reorganize-var-desc">${v.desc}</span>`;
html += '</div>';
});
html += '</div></div>';
// Preview area
html += '<div class="reorganize-preview-section">';
html += '<div class="reorganize-preview-header">';
html += '<label class="reorganize-label">Preview</label>';
html += '<button class="reorganize-preview-btn" onclick="loadReorganizePreview()">Generate Preview</button>';
html += '</div>';
html += '<div id="reorganize-preview-body" class="reorganize-preview-body">';
html += '<div class="reorganize-preview-hint">Click "Generate Preview" to see how files will be reorganized.</div>';
html += '</div></div>';
html += '</div>';
body.innerHTML = html;
overlay.classList.remove('hidden');
// Wire up live preview on enter key
setTimeout(() => {
const input = document.getElementById('reorganize-template-input');
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
loadReorganizePreview();
}
});
input.focus();
}
}, 50);
}
function insertReorganizeVar(varName) {
const input = document.getElementById('reorganize-template-input');
if (!input) return;
const start = input.selectionStart;
const end = input.selectionEnd;
const val = input.value;
input.value = val.substring(0, start) + varName + val.substring(end);
input.focus();
const newPos = start + varName.length;
input.setSelectionRange(newPos, newPos);
}
function closeReorganizeModal() {
const overlay = document.getElementById('reorganize-overlay');
if (overlay) overlay.classList.add('hidden');
_reorganizeAlbumId = null;
}
async function loadReorganizePreview() {
const template = document.getElementById('reorganize-template-input')?.value?.trim();
const previewBody = document.getElementById('reorganize-preview-body');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!template || !previewBody || !_reorganizeAlbumId) return;
if (applyBtn) applyBtn.disabled = true;
previewBody.innerHTML = '<div class="reorganize-preview-loading">Loading preview...</div>';
try {
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template })
});
const result = await response.json();
if (!result.success) {
previewBody.innerHTML = `<div class="reorganize-preview-error">${escapeHtml(result.error || 'Preview failed')}</div>`;
return;
}
const tracks = result.tracks || [];
if (tracks.length === 0) {
previewBody.innerHTML = '<div class="reorganize-preview-hint">No tracks found.</div>';
return;
}
let hasChanges = false;
let hasCollisions = false;
let html = '<table class="reorganize-preview-table"><thead><tr>';
html += '<th>#</th><th>Title</th><th>Current Path</th><th></th><th>New Path</th>';
html += '</tr></thead><tbody>';
tracks.forEach(t => {
const unchanged = t.unchanged;
const noFile = !t.file_exists;
const collision = t.collision;
if (!unchanged && t.file_exists) hasChanges = true;
if (collision) hasCollisions = true;
const rowClass = collision ? 'reorganize-row-collision' : noFile ? 'reorganize-row-missing' : unchanged ? 'reorganize-row-unchanged' : 'reorganize-row-changed';
html += `<tr class="${rowClass}">`;
html += `<td>${t.track_number || ''}</td>`;
html += `<td>${escapeHtml(t.title)}</td>`;
html += `<td class="reorganize-path">${noFile ? '<em>File not found</em>' : escapeHtml(t.current_path)}</td>`;
html += `<td class="reorganize-arrow">${collision ? '!!' : unchanged ? '=' : noFile ? '' : '→'}</td>`;
html += `<td class="reorganize-path">${noFile ? '' : escapeHtml(t.new_path)}${collision ? ' <em>(collision)</em>' : ''}</td>`;
html += '</tr>';
});
html += '</tbody></table>';
const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision).length;
const skippedCount = tracks.filter(t => t.unchanged).length;
const missingCount = tracks.filter(t => !t.file_exists).length;
const collisionCount = tracks.filter(t => t.collision).length;
let summary = `<div class="reorganize-preview-summary">`;
if (changedCount > 0) summary += `<span class="reorganize-stat changed">${changedCount} will move</span>`;
if (skippedCount > 0) summary += `<span class="reorganize-stat unchanged">${skippedCount} unchanged</span>`;
if (missingCount > 0) summary += `<span class="reorganize-stat missing">${missingCount} missing</span>`;
if (collisionCount > 0) summary += `<span class="reorganize-stat collision">${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — add $track or $disc to fix</span>`;
summary += '</div>';
previewBody.innerHTML = summary + html;
// Block apply if collisions exist
if (applyBtn) applyBtn.disabled = !hasChanges || hasCollisions;
} catch (error) {
previewBody.innerHTML = `<div class="reorganize-preview-error">Error: ${escapeHtml(error.message)}</div>`;
}
}
async function executeReorganize() {
const template = document.getElementById('reorganize-template-input')?.value?.trim();
if (!template || !_reorganizeAlbumId) return;
const applyBtn = document.getElementById('reorganize-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = 'Reorganizing...';
}
try {
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
closeReorganizeModal();
showToast(`Reorganizing ${result.total} tracks...`, 'info');
_pollReorganizeStatus();
} catch (error) {
showToast(`Reorganize failed: ${error.message}`, 'error');
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = 'Apply';
}
}
}
function _pollReorganizeStatus() {
if (_reorganizePollTimer) clearTimeout(_reorganizePollTimer);
async function poll() {
try {
const response = await fetch('/api/library/album/reorganize/status');
const state = await response.json();
if (state.status === 'running') {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Reorganizing: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info');
_reorganizePollTimer = setTimeout(poll, 800);
} else if (state.status === 'done') {
let msg = `Reorganized: ${state.moved} moved`;
if (state.skipped > 0) msg += `, ${state.skipped} skipped`;
if (state.failed > 0) msg += `, ${state.failed} failed`;
if (state.failed > 0 && state.errors && state.errors.length > 0) {
msg += ` (${state.errors[0].error})`;
}
showToast(msg, state.failed > 0 ? 'warning' : 'success');
_reorganizePollTimer = null;
// Refresh the enhanced view to show updated paths
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
}
} catch (error) {
console.error('Poll reorganize status failed:', error);
_reorganizePollTimer = null;
}
}
_reorganizePollTimer = setTimeout(poll, 600);
}
async function playLibraryTrack(track, albumTitle, artistName) {
if (!track.file_path) {
showToast('No file available for this track', 'error');

@ -34514,6 +34514,235 @@ textarea.enhanced-meta-field-input {
color: rgba(29, 185, 84, 0.9);
border-color: rgba(29, 185, 84, 0.35);
}
.enhanced-reorganize-album-btn {
padding: 6px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: rgba(100, 149, 237, 0.06);
border: 1px solid rgba(100, 149, 237, 0.15);
color: rgba(100, 149, 237, 0.6);
transition: all 0.15s ease;
}
.enhanced-reorganize-album-btn:hover {
background: rgba(100, 149, 237, 0.12);
color: rgba(100, 149, 237, 0.9);
border-color: rgba(100, 149, 237, 0.35);
}
/* ── Reorganize Modal ── */
.reorganize-modal {
max-width: 900px;
width: 95vw;
}
.reorganize-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.reorganize-label {
display: block;
font-size: 13px;
font-weight: 600;
color: rgba(255,255,255,0.8);
margin-bottom: 6px;
}
.reorganize-template-section {
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.reorganize-template-hint {
font-size: 11px;
color: rgba(255,255,255,0.35);
margin-bottom: 8px;
}
.reorganize-template-hint code {
background: rgba(255,255,255,0.06);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.reorganize-template-input {
width: 100%;
padding: 10px 14px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
color: #e0e0e0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.reorganize-template-input:focus {
border-color: rgba(100, 149, 237, 0.5);
}
.reorganize-variables {
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.reorganize-var-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.reorganize-var-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
font-size: 12px;
}
.reorganize-var-chip:hover {
background: rgba(100, 149, 237, 0.1);
border-color: rgba(100, 149, 237, 0.3);
}
.reorganize-var-chip code {
color: rgba(100, 149, 237, 0.9);
font-size: 12px;
font-weight: 600;
}
.reorganize-var-desc {
color: rgba(255,255,255,0.4);
font-size: 11px;
}
.reorganize-preview-section {
flex: 1;
}
.reorganize-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.reorganize-preview-header .reorganize-label {
margin-bottom: 0;
}
.reorganize-preview-btn {
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: rgba(100, 149, 237, 0.1);
border: 1px solid rgba(100, 149, 237, 0.25);
color: rgba(100, 149, 237, 0.9);
transition: all 0.15s;
}
.reorganize-preview-btn:hover {
background: rgba(100, 149, 237, 0.2);
border-color: rgba(100, 149, 237, 0.4);
}
.reorganize-preview-body {
max-height: 350px;
overflow-y: auto;
background: rgba(0,0,0,0.15);
border-radius: 8px;
padding: 10px;
}
.reorganize-preview-hint,
.reorganize-preview-loading {
text-align: center;
color: rgba(255,255,255,0.3);
font-size: 12px;
padding: 24px;
}
.reorganize-preview-error {
text-align: center;
color: rgba(255, 80, 80, 0.8);
font-size: 12px;
padding: 24px;
}
.reorganize-preview-summary {
display: flex;
gap: 12px;
margin-bottom: 10px;
font-size: 12px;
}
.reorganize-stat {
padding: 3px 10px;
border-radius: 12px;
font-weight: 500;
}
.reorganize-stat.changed {
background: rgba(100, 149, 237, 0.12);
color: rgba(100, 149, 237, 0.9);
}
.reorganize-stat.unchanged {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.4);
}
.reorganize-stat.missing {
background: rgba(255, 170, 50, 0.1);
color: rgba(255, 170, 50, 0.8);
}
.reorganize-preview-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.reorganize-preview-table th {
text-align: left;
padding: 6px 8px;
color: rgba(255,255,255,0.4);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.reorganize-preview-table td {
padding: 6px 8px;
border-bottom: 1px solid rgba(255,255,255,0.04);
color: rgba(255,255,255,0.7);
vertical-align: top;
}
.reorganize-path {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
word-break: break-all;
max-width: 300px;
}
.reorganize-arrow {
text-align: center;
color: rgba(100, 149, 237, 0.7);
font-weight: bold;
white-space: nowrap;
}
.reorganize-row-changed .reorganize-path:last-child {
color: rgba(100, 149, 237, 0.9);
}
.reorganize-row-unchanged td {
color: rgba(255,255,255,0.25);
}
.reorganize-row-missing td {
color: rgba(255, 170, 50, 0.5);
}
.reorganize-row-missing em {
font-style: italic;
}
.reorganize-row-collision td {
color: rgba(255, 60, 60, 0.8);
}
.reorganize-row-collision .reorganize-arrow {
color: rgba(255, 60, 60, 0.9);
font-weight: bold;
}
.reorganize-row-collision em {
font-style: italic;
color: rgba(255, 60, 60, 0.6);
}
.reorganize-stat.collision {
background: rgba(255, 60, 60, 0.1);
color: rgba(255, 60, 60, 0.9);
}
.enhanced-bulk-btn.tag-write {
background: rgba(29, 185, 84, 0.08);

Loading…
Cancel
Save