Add album file reorganization to Enhanced Library Manager
This commit is contained in:
parent
d4eadef374
commit
57a8bdd107
4 changed files with 929 additions and 0 deletions
426
web_server.py
426
web_server.py
|
|
@ -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()">×</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 = '📁 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…
Reference in a new issue