enhanced library write all modal and confirmation dialog

This commit is contained in:
Broque Thomas 2026-03-12 16:57:38 -07:00
parent 6c4de45b32
commit e8df863205
4 changed files with 497 additions and 17 deletions

View file

@ -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/<track_id>/write-tags', methods=['POST'])
def write_track_tags(track_id):
"""Write DB metadata into the audio file tags for a single track."""

View file

@ -2598,6 +2598,34 @@
</div>
</div>
<!-- Batch Tag Preview Modal -->
<div class="modal-overlay hidden" id="batch-tag-preview-overlay">
<div class="enhanced-bulk-modal batch-tag-preview-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="batch-tag-preview-title">Write Tags</h3>
<button class="enhanced-bulk-modal-close" onclick="closeBatchTagPreviewModal()">&times;</button>
</div>
<div id="batch-tag-preview-summary"></div>
<div class="enhanced-bulk-modal-body batch-tag-preview-body" id="batch-tag-preview-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer">
<label class="tag-preview-cover-label">
<input type="checkbox" id="batch-tag-preview-embed-cover" checked>
Embed cover art
</label>
<label class="tag-preview-cover-label hidden" id="batch-tag-preview-sync-label">
<input type="checkbox" id="batch-tag-preview-sync-server" checked>
<span id="batch-tag-preview-sync-text">Sync to server</span>
</label>
<div class="tag-preview-footer-actions">
<button class="enhanced-bulk-btn secondary" onclick="closeBatchTagPreviewModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="batch-tag-preview-write-btn" onclick="executeBatchWriteTags()">Write Tags</button>
</div>
</div>
</div>
</div>
<!-- Discover Page -->
<div class="page" id="discover-page">
<div class="discover-container">

View file

@ -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 = '<div class="tag-preview-loading">Loading tag previews...</div>';
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 = `<div class="tag-preview-error">${escapeHtml(result.error)}</div>`;
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 = '<div class="batch-tag-summary">';
if (withChanges.length > 0) summaryHtml += `<span class="batch-tag-stat changed">${withChanges.length} with changes</span>`;
if (noChanges.length > 0) summaryHtml += `<span class="batch-tag-stat unchanged">${noChanges.length} unchanged</span>`;
if (errors.length > 0) summaryHtml += `<span class="batch-tag-stat errored">${errors.length} unavailable</span>`;
summaryHtml += '</div>';
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 += `<div class="batch-tag-track error">`;
html += `<div class="batch-tag-track-header">`;
html += `<span class="batch-tag-track-number">${track.track_number || '—'}</span>`;
html += `<span class="batch-tag-track-title">${escapeHtml(track.title)}</span>`;
html += `<span class="batch-tag-track-status error">${escapeHtml(track.error)}</span>`;
html += `</div></div>`;
});
// Unchanged tracks (collapsed)
if (noChanges.length > 0) {
html += `<div class="batch-tag-unchanged-group">`;
html += `<div class="batch-tag-unchanged-header" onclick="this.parentElement.classList.toggle('expanded')">`;
html += `<span>${noChanges.length} track${noChanges.length !== 1 ? 's' : ''} already up to date</span>`;
html += `<span class="batch-tag-chevron">&#9662;</span>`;
html += `</div>`;
html += `<div class="batch-tag-unchanged-list">`;
noChanges.forEach(track => {
html += `<div class="batch-tag-track-row unchanged">`;
html += `<span class="batch-tag-track-number">${track.track_number || '—'}</span>`;
html += `<span class="batch-tag-track-title">${escapeHtml(track.title)}</span>`;
html += `<span class="batch-tag-track-status ok">✓ Tags match</span>`;
html += `</div>`;
});
html += `</div></div>`;
}
if (withChanges.length === 0 && errors.length === 0) {
html += '<div class="tag-preview-no-changes">All file tags already match DB metadata</div>';
}
body.innerHTML = html;
// Store state for write action
overlay._batchTrackIds = trackIds;
overlay._batchServerType = serverType;
writeBtn.disabled = withChanges.length === 0;
} catch (error) {
body.innerHTML = `<div class="tag-preview-error">Failed to load previews: ${escapeHtml(error.message)}</div>`;
}
}
function _renderBatchTrackDiff(track, expanded) {
let html = `<div class="batch-tag-track${expanded ? ' expanded' : ''}">`;
html += `<div class="batch-tag-track-header" onclick="this.parentElement.classList.toggle('expanded')">`;
html += `<span class="batch-tag-track-number">${track.track_number || '—'}</span>`;
html += `<span class="batch-tag-track-title">${escapeHtml(track.title)}</span>`;
html += `<span class="batch-tag-track-status changed">${track.changed_count} field${track.changed_count !== 1 ? 's' : ''} changed</span>`;
html += `<span class="batch-tag-chevron">&#9662;</span>`;
html += `</div>`;
html += `<div class="batch-tag-track-diff">`;
html += '<table class="tag-preview-table"><thead><tr>';
html += '<th>Field</th><th>Current File</th><th></th><th>New Value</th>';
html += '</tr></thead><tbody>';
(track.diff || []).forEach(d => {
if (!d.changed) return; // Only show changed fields in batch view
html += `<tr class="tag-diff-changed">`;
html += `<td class="tag-field-name">${d.field}</td>`;
html += `<td class="tag-file-value">${escapeHtml(d.file_value) || '<span class="tag-empty">empty</span>'}</td>`;
html += `<td class="tag-diff-indicator"><span class="tag-diff-arrow">&rarr;</span></td>`;
html += `<td class="tag-db-value">${escapeHtml(d.db_value) || '<span class="tag-empty">empty</span>'}</td>`;
html += '</tr>';
});
html += '</tbody></table></div></div>';
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) {

View file

@ -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 == */
/* ======================================================= */