From e8df863205f865d96e18593463182415fdb19ce7 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:57:38 -0700 Subject: [PATCH] enhanced library write all modal and confirmation dialog --- web_server.py | 105 ++++++++++++++++++++++ webui/index.html | 28 ++++++ webui/static/script.js | 185 ++++++++++++++++++++++++++++++++++---- webui/static/style.css | 196 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 497 insertions(+), 17 deletions(-) diff --git a/web_server.py b/web_server.py index 80bb3026..72c64abe 100644 --- a/web_server.py +++ b/web_server.py @@ -9329,6 +9329,111 @@ def get_track_tag_preview(track_id): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/library/tracks/tag-preview-batch', methods=['POST']) +def get_batch_tag_preview(): + """Read current file tags and compare against DB metadata for multiple tracks.""" + try: + from core.tag_writer import read_file_tags, build_tag_diff + data = request.get_json() + track_ids = data.get('track_ids', []) + if not track_ids: + return jsonify({"success": False, "error": "No track IDs provided"}), 400 + + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + + placeholders = ','.join('?' for _ in track_ids) + cursor.execute(f""" + SELECT t.*, a.name as artist_name, al.title as album_title, + al.year, al.genres as album_genres, al.track_count, + al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url + FROM tracks t + JOIN artists a ON t.artist_id = a.id + JOIN albums al ON t.album_id = al.id + WHERE t.id IN ({placeholders}) + """, [str(tid) for tid in track_ids]) + rows = [dict(r) for r in cursor.fetchall()] + + results = [] + for track_data in rows: + track_id = track_data['id'] + file_path = track_data.get('file_path') + resolved_path = _resolve_library_file_path(file_path) + + entry = { + 'track_id': track_id, + 'title': track_data.get('title', 'Unknown'), + 'track_number': track_data.get('track_number'), + } + + if not resolved_path: + entry['error'] = 'File not found' + results.append(entry) + continue + + try: + file_tags = read_file_tags(resolved_path) + if file_tags.get('error'): + entry['error'] = file_tags['error'] + results.append(entry) + continue + + album_genres = [] + if track_data.get('album_genres'): + try: + parsed = json.loads(track_data['album_genres']) + album_genres = parsed if isinstance(parsed, list) else [str(parsed)] + except (ValueError, TypeError): + album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] + + db_data = { + 'title': track_data.get('title'), + 'artist_name': track_data.get('artist_name'), + 'album_title': track_data.get('album_title'), + 'year': track_data.get('year'), + 'genres': album_genres, + 'track_number': track_data.get('track_number'), + 'disc_number': track_data.get('disc_number'), + 'bpm': track_data.get('bpm'), + 'track_count': track_data.get('track_count'), + 'thumb_url': track_data.get('album_thumb_url') or track_data.get('artist_thumb_url'), + } + + diff = build_tag_diff(file_tags, db_data) + has_changes = any(d['changed'] for d in diff) + changed_fields = [d for d in diff if d['changed']] + + entry['diff'] = diff + entry['has_changes'] = has_changes + entry['changed_count'] = len(changed_fields) + + except Exception as e: + entry['error'] = str(e) + + results.append(entry) + + # Server type info + active_server = config_manager.get_active_media_server() + server_connected = False + if active_server == 'plex': + server_connected = plex_client.is_connected() + elif active_server == 'jellyfin': + server_connected = jellyfin_client.is_connected() + elif active_server == 'navidrome': + server_connected = navidrome_client.is_connected() + + return jsonify({ + "success": True, + "tracks": results, + "server_type": active_server if server_connected else None, + }) + + except Exception as e: + logger.error(f"Batch tag preview error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/library/track//write-tags', methods=['POST']) def write_track_tags(track_id): """Write DB metadata into the audio file tags for a single track.""" diff --git a/webui/index.html b/webui/index.html index df5915d6..c3301c33 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2598,6 +2598,34 @@ + + +
diff --git a/webui/static/script.js b/webui/static/script.js index 0e454070..fd18414a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -35130,7 +35130,7 @@ function sortEnhancedTracks(album, field, ascending) { async function deleteLibraryTrack(trackId, albumId) { cancelInlineEdit(); - if (!confirm('Delete this track from the library? (File on disk is not affected)')) return; + if (!await showConfirmDialog({ title: 'Delete Track', message: 'Delete this track from the library? (File on disk is not affected)', confirmText: 'Delete', destructive: true })) return; try { const response = await fetch(`/api/library/track/${trackId}`, { method: 'DELETE' }); const result = await response.json(); @@ -35151,7 +35151,7 @@ async function deleteLibraryTrack(trackId, albumId) { } async function deleteLibraryAlbum(albumId) { - if (!confirm('Delete this album and all its tracks from the library? (Files on disk are not affected)')) return; + if (!await showConfirmDialog({ title: 'Delete Album', message: 'Delete this album and all its tracks from the library? (Files on disk are not affected)', confirmText: 'Delete', destructive: true })) return; try { const response = await fetch(`/api/library/album/${albumId}`, { method: 'DELETE' }); const result = await response.json(); @@ -36060,26 +36060,179 @@ async function writeAlbumTags(albumId) { return; } - const serverType = artistDetailPageState.serverType; - const canSync = serverType && serverType !== 'navidrome'; - const serverLabel = serverType === 'plex' ? 'Plex' : serverType === 'jellyfin' ? 'Jellyfin' : ''; - let msg = `Write DB metadata to file tags for all ${tracks.length} tracks in "${album.title}"?`; - if (canSync) msg += `\n\nThis will also sync changes to ${serverLabel}.`; - if (!confirm(msg)) return; - await _startBatchWriteTags(tracks.map(t => t.id), true, canSync); + await showBatchTagPreview(tracks.map(t => t.id), album.title); } async function batchWriteTagsSelected() { const trackIds = Array.from(artistDetailPageState.selectedTracks); if (trackIds.length === 0) return; - const serverType = artistDetailPageState.serverType; - const canSync = serverType && serverType !== 'navidrome'; - const serverLabel = serverType === 'plex' ? 'Plex' : serverType === 'jellyfin' ? 'Jellyfin' : ''; - let msg = `Write DB metadata to file tags for ${trackIds.length} selected track(s)?`; - if (canSync) msg += `\n\nThis will also sync changes to ${serverLabel}.`; - if (!confirm(msg)) return; - await _startBatchWriteTags(trackIds, true, canSync); + await showBatchTagPreview(trackIds, null); +} + +async function showBatchTagPreview(trackIds, albumTitle) { + const overlay = document.getElementById('batch-tag-preview-overlay'); + const body = document.getElementById('batch-tag-preview-body'); + const titleEl = document.getElementById('batch-tag-preview-title'); + const summary = document.getElementById('batch-tag-preview-summary'); + const writeBtn = document.getElementById('batch-tag-preview-write-btn'); + if (!overlay || !body) return; + + titleEl.textContent = albumTitle ? `Write Tags — ${albumTitle}` : `Write Tags — ${trackIds.length} Tracks`; + body.innerHTML = '
Loading tag previews...
'; + summary.innerHTML = ''; + writeBtn.disabled = true; + overlay.classList.remove('hidden'); + + // Hide sync checkbox until we know server type + const syncLabel = document.getElementById('batch-tag-preview-sync-label'); + if (syncLabel) syncLabel.classList.add('hidden'); + + try { + const response = await fetch('/api/library/tracks/tag-preview-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds }) + }); + const result = await response.json(); + if (!result.success) { + body.innerHTML = `
${escapeHtml(result.error)}
`; + return; + } + + const tracks = result.tracks || []; + const serverType = result.server_type || null; + + // Show sync checkbox if server connected + if (syncLabel && serverType && serverType !== 'navidrome') { + const syncText = document.getElementById('batch-tag-preview-sync-text'); + if (syncText) syncText.textContent = `Sync to ${serverType === 'plex' ? 'Plex' : 'Jellyfin'}`; + syncLabel.classList.remove('hidden'); + } + + // Categorize tracks + const withChanges = tracks.filter(t => t.has_changes); + const noChanges = tracks.filter(t => !t.error && !t.has_changes); + const errors = tracks.filter(t => t.error); + + // Summary bar + let summaryHtml = '
'; + if (withChanges.length > 0) summaryHtml += `${withChanges.length} with changes`; + if (noChanges.length > 0) summaryHtml += `${noChanges.length} unchanged`; + if (errors.length > 0) summaryHtml += `${errors.length} unavailable`; + summaryHtml += '
'; + summary.innerHTML = summaryHtml; + + // Build track accordion + let html = ''; + + // Tracks with changes (expanded by default) + withChanges.forEach(track => { + html += _renderBatchTrackDiff(track, true); + }); + + // Errors + errors.forEach(track => { + html += `
`; + html += `
`; + html += `${track.track_number || '—'}`; + html += `${escapeHtml(track.title)}`; + html += `${escapeHtml(track.error)}`; + html += `
`; + }); + + // Unchanged tracks (collapsed) + if (noChanges.length > 0) { + html += `
`; + html += `
`; + html += `${noChanges.length} track${noChanges.length !== 1 ? 's' : ''} already up to date`; + html += ``; + html += `
`; + html += `
`; + noChanges.forEach(track => { + html += `
`; + html += `${track.track_number || '—'}`; + html += `${escapeHtml(track.title)}`; + html += `✓ Tags match`; + html += `
`; + }); + html += `
`; + } + + if (withChanges.length === 0 && errors.length === 0) { + html += '
All file tags already match DB metadata
'; + } + + body.innerHTML = html; + + // Store state for write action + overlay._batchTrackIds = trackIds; + overlay._batchServerType = serverType; + writeBtn.disabled = withChanges.length === 0; + + } catch (error) { + body.innerHTML = `
Failed to load previews: ${escapeHtml(error.message)}
`; + } +} + +function _renderBatchTrackDiff(track, expanded) { + let html = `
`; + html += `
`; + html += `${track.track_number || '—'}`; + html += `${escapeHtml(track.title)}`; + html += `${track.changed_count} field${track.changed_count !== 1 ? 's' : ''} changed`; + html += ``; + html += `
`; + html += `
`; + html += ''; + html += ''; + html += ''; + + (track.diff || []).forEach(d => { + if (!d.changed) return; // Only show changed fields in batch view + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + + html += '
FieldCurrent FileNew Value
${d.field}${escapeHtml(d.file_value) || 'empty'}${escapeHtml(d.db_value) || 'empty'}
'; + return html; +} + +function closeBatchTagPreviewModal() { + const overlay = document.getElementById('batch-tag-preview-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + overlay._batchTrackIds = null; + overlay._batchServerType = null; + } +} + +async function executeBatchWriteTags() { + const overlay = document.getElementById('batch-tag-preview-overlay'); + const trackIds = overlay?._batchTrackIds; + if (!trackIds || trackIds.length === 0) return; + + const writeBtn = document.getElementById('batch-tag-preview-write-btn'); + if (writeBtn) { + writeBtn.disabled = true; + writeBtn.textContent = 'Writing...'; + } + + const embedCover = document.getElementById('batch-tag-preview-embed-cover')?.checked ?? true; + const serverType = overlay._batchServerType; + const syncToServer = document.getElementById('batch-tag-preview-sync-server')?.checked && serverType && serverType !== 'navidrome'; + + closeBatchTagPreviewModal(); + await _startBatchWriteTags(trackIds, embedCover, syncToServer); + + if (writeBtn) { + writeBtn.disabled = false; + writeBtn.textContent = 'Write Tags'; + } } async function _startBatchWriteTags(trackIds, embedCover, syncToServer = false) { diff --git a/webui/static/style.css b/webui/static/style.css index 0f190a51..7d982bdb 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -32729,7 +32729,6 @@ textarea.enhanced-meta-field-input { width: 500px; max-width: 90vw; box-shadow: 0 25px 80px rgba(0,0,0,0.7); - overflow: hidden; } .enhanced-bulk-modal-header { padding: 20px 24px; @@ -34106,6 +34105,201 @@ tr.tag-diff-same { justify-content: space-between; } +/* Batch Tag Preview Modal */ +.batch-tag-preview-modal { + width: 850px; + max-width: 92vw; + max-height: 85vh; + display: flex; + flex-direction: column; +} + +.batch-tag-preview-modal .enhanced-bulk-modal-footer { + justify-content: space-between; +} + +.batch-tag-preview-body { + overflow-y: auto; + max-height: 55vh; + padding: 8px 16px 16px !important; +} + +.batch-tag-summary { + display: flex; + gap: 12px; + padding: 10px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.batch-tag-stat { + font-size: 12px; + font-weight: 500; + padding: 3px 10px; + border-radius: 10px; +} + +.batch-tag-stat.changed { + background: rgba(var(--accent-rgb), 0.1); + color: rgb(var(--accent-rgb)); +} + +.batch-tag-stat.unchanged { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.4); +} + +.batch-tag-stat.errored { + background: rgba(255, 80, 80, 0.1); + color: rgba(255, 80, 80, 0.7); +} + +.batch-tag-track { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + margin-bottom: 6px; +} + +.batch-tag-track.error { + border-color: rgba(255, 80, 80, 0.15); +} + +.batch-tag-track-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + cursor: pointer; + transition: background 0.15s ease; +} + +.batch-tag-track-header:hover { + background: rgba(255, 255, 255, 0.03); +} + +.batch-tag-track-number { + color: rgba(255, 255, 255, 0.3); + font-size: 12px; + font-weight: 500; + min-width: 20px; + text-align: right; +} + +.batch-tag-track-title { + flex: 1; + font-size: 13px; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.batch-tag-track-status { + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 8px; +} + +.batch-tag-track-status.changed { + background: rgba(var(--accent-rgb), 0.1); + color: rgb(var(--accent-rgb)); +} + +.batch-tag-track-status.error { + background: rgba(255, 80, 80, 0.1); + color: rgba(255, 80, 80, 0.7); +} + +.batch-tag-track-status.ok { + color: rgba(255, 255, 255, 0.3); +} + +.batch-tag-chevron { + color: rgba(255, 255, 255, 0.2); + font-size: 11px; + transition: transform 0.2s ease; +} + +.batch-tag-track.expanded .batch-tag-chevron { + transform: rotate(180deg); +} + +.batch-tag-track-diff { + display: none; + padding: 0 14px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.batch-tag-track.expanded .batch-tag-track-diff { + display: block; +} + +.batch-tag-track-diff .tag-preview-table { + font-size: 12px; +} + +.batch-tag-track-diff .tag-preview-table th { + font-size: 10px; + padding: 6px 8px; +} + +.batch-tag-track-diff .tag-preview-table td { + padding: 5px 8px; +} + +/* Unchanged tracks group */ +.batch-tag-unchanged-group { + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 10px; + margin-bottom: 6px; + overflow: hidden; +} + +.batch-tag-unchanged-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + font-size: 12px; + color: rgba(255, 255, 255, 0.35); + transition: background 0.15s ease; +} + +.batch-tag-unchanged-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.batch-tag-unchanged-list { + display: none; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.batch-tag-unchanged-group.expanded .batch-tag-unchanged-list { + display: block; +} + +.batch-tag-unchanged-group.expanded .batch-tag-chevron { + transform: rotate(180deg); +} + +.batch-tag-track-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.02); +} + +.batch-tag-track-row:last-child { + border-bottom: none; +} + +.batch-tag-track-row.unchanged { + opacity: 0.5; +} + /* ======================================================= */ /* == HELP & DOCS PAGE == */ /* ======================================================= */