Add server sync option to enhanced library write-tags flow

pull/253/head
Broque Thomas 2 months ago
parent 729f42f1a4
commit cc4502e5f8

@ -8769,6 +8769,17 @@ def get_artist_enhanced_detail(artist_id):
if album.get('thumb_url'):
album['thumb_url'] = fix_artist_image_url(album['thumb_url'])
# Include server type for sync option
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()
result['server_type'] = active_server if server_connected else None
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@ -8902,6 +8913,16 @@ def get_track_tag_preview(track_id):
diff = build_tag_diff(file_tags, db_data)
has_changes = any(d['changed'] for d in diff)
# Include server type so frontend can offer server sync option
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,
"file_path": resolved_path,
@ -8909,6 +8930,7 @@ def get_track_tag_preview(track_id):
"db_data": db_data,
"diff": diff,
"has_changes": has_changes,
"server_type": active_server if server_connected else None,
})
except Exception as e:
@ -8982,6 +9004,13 @@ def write_track_tags(track_id):
with file_lock:
result = write_tags_to_file(resolved_path, db_data, embed_cover=embed_cover, cover_url=cover_url)
# Sync to media server if requested and write succeeded
sync_result = None
if result.get('success') and data.get('sync_to_server'):
server_type = config_manager.get_active_media_server()
sync_result = _sync_tracks_to_server([track_data], server_type)
result['server_sync'] = sync_result
return jsonify(result)
except Exception as e:
@ -8997,6 +9026,10 @@ _write_tags_batch_state = {
'failed': 0,
'current_track': '',
'errors': [],
'sync_phase': None, # None | 'syncing' | 'done'
'sync_server': None,
'sync_synced': 0,
'sync_failed': 0,
}
_write_tags_batch_lock = threading.Lock()
@ -9033,6 +9066,8 @@ def write_tracks_tags_batch():
rows = [dict(r) for r in cursor.fetchall()]
sync_to_server = data.get('sync_to_server', False)
# Initialize state
with _write_tags_batch_lock:
_write_tags_batch_state.update({
@ -9043,6 +9078,10 @@ def write_tracks_tags_batch():
'failed': 0,
'current_track': '',
'errors': [],
'sync_phase': None,
'sync_server': None,
'sync_synced': 0,
'sync_failed': 0,
})
# Count missing DB rows
@ -9060,6 +9099,8 @@ def write_tracks_tags_batch():
try:
from core.tag_writer import write_tags_to_file, download_cover_art
written_tracks = [] # Track dicts that were successfully written (for server sync)
# Pre-download cover art once per unique album URL
cover_cache = {} # url → (bytes, mime) or None
if embed_cover:
@ -9129,6 +9170,7 @@ def write_tracks_tags_batch():
_write_tags_batch_state['processed'] += 1
if write_result.get('success'):
_write_tags_batch_state['written'] += 1
written_tracks.append(track_data)
else:
_write_tags_batch_state['failed'] += 1
_write_tags_batch_state['errors'].append({
@ -9136,6 +9178,21 @@ def write_tracks_tags_batch():
'error': write_result.get('error', 'Unknown')
})
# Server sync phase
if sync_to_server and written_tracks:
server_type = config_manager.get_active_media_server()
with _write_tags_batch_lock:
_write_tags_batch_state['sync_phase'] = 'syncing'
_write_tags_batch_state['sync_server'] = server_type
_write_tags_batch_state['current_track'] = f'Syncing to {server_type.title()}...'
sync_result = _sync_tracks_to_server(written_tracks, server_type)
with _write_tags_batch_lock:
_write_tags_batch_state['sync_phase'] = 'done'
_write_tags_batch_state['sync_synced'] = sync_result['synced']
_write_tags_batch_state['sync_failed'] = sync_result['failed']
except Exception as e:
logger.error(f"Batch write tags background error: {e}")
finally:
@ -9164,6 +9221,68 @@ def get_write_tags_batch_status():
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.
Args:
track_rows: list of track dicts (must include 'id', 'title', 'artist_name', 'album_title', 'year', 'server_source')
server_type: 'plex', 'jellyfin', or 'navidrome'
Returns:
dict with 'synced', 'failed', 'skipped' counts and 'errors' list
"""
result = {'synced': 0, 'failed': 0, 'skipped': 0, 'errors': []}
if server_type == 'navidrome':
# Navidrome auto-detects file tag changes, no action needed
result['synced'] = len(track_rows)
return result
if server_type == 'plex':
for track_data in track_rows:
# Only sync tracks that came from this server
if track_data.get('server_source') and track_data['server_source'] != 'plex':
result['skipped'] += 1
continue
try:
metadata = {}
if track_data.get('title'):
metadata['title'] = track_data['title']
if track_data.get('artist_name'):
metadata['artist'] = track_data['artist_name']
if track_data.get('album_title'):
metadata['album'] = track_data['album_title']
if track_data.get('year'):
metadata['year'] = track_data['year']
if metadata:
success = plex_client.update_track_metadata(str(track_data['id']), metadata)
if success:
result['synced'] += 1
else:
result['failed'] += 1
result['errors'].append({'track_id': track_data['id'], 'error': 'Plex update returned false'})
else:
result['skipped'] += 1
except Exception as e:
result['failed'] += 1
result['errors'].append({'track_id': track_data['id'], 'error': str(e)})
elif server_type == 'jellyfin':
# Jellyfin: just trigger a library scan once after all file writes
try:
success = jellyfin_client.trigger_library_scan()
if success:
result['synced'] = len(track_rows)
else:
result['failed'] = len(track_rows)
result['errors'].append({'error': 'Jellyfin library scan failed'})
except Exception as e:
result['failed'] = len(track_rows)
result['errors'].append({'error': f'Jellyfin scan error: {e}'})
return result
def _resolve_library_file_path(file_path):
"""Resolve a library file path to an actual file on disk."""
if not file_path:

@ -2367,6 +2367,10 @@
<input type="checkbox" id="tag-preview-embed-cover" checked>
Embed cover art
</label>
<label class="tag-preview-cover-label hidden" id="tag-preview-sync-label">
<input type="checkbox" id="tag-preview-sync-server" checked>
<span id="tag-preview-sync-text">Sync to server</span>
</label>
<div class="tag-preview-footer-actions">
<button class="enhanced-bulk-btn secondary" onclick="closeTagPreviewModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="tag-preview-write-btn" onclick="executeWriteTags()">Write Tags</button>

@ -33140,6 +33140,8 @@ async function loadEnhancedViewData(artistId) {
artistDetailPageState.expandedAlbums = new Set();
artistDetailPageState.selectedTracks = new Set();
artistDetailPageState.enhancedTrackSort = {};
artistDetailPageState.serverType = data.server_type || null;
_tagPreviewServerType = data.server_type || null;
renderEnhancedView();
} catch (error) {
@ -34902,9 +34904,11 @@ document.addEventListener('click', (e) => {
// ---- Write Tags to File ----
let _tagPreviewTrackId = null;
let _tagPreviewServerType = null;
async function showTagPreview(trackId) {
_tagPreviewTrackId = trackId;
_tagPreviewServerType = null;
const overlay = document.getElementById('tag-preview-overlay');
const body = document.getElementById('tag-preview-body');
const title = document.getElementById('tag-preview-title');
@ -34914,6 +34918,10 @@ async function showTagPreview(trackId) {
body.innerHTML = '<div class="tag-preview-loading">Loading tag comparison...</div>';
overlay.classList.remove('hidden');
// Hide sync checkbox until we know server type
const syncLabel = document.getElementById('tag-preview-sync-label');
if (syncLabel) syncLabel.classList.add('hidden');
try {
const response = await fetch(`/api/library/track/${trackId}/tag-preview`);
const result = await response.json();
@ -34925,6 +34933,14 @@ async function showTagPreview(trackId) {
const diff = result.diff || [];
const hasChanges = result.has_changes;
// Show server sync checkbox if a server is connected (not navidrome — it auto-detects)
_tagPreviewServerType = result.server_type || null;
if (syncLabel && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome') {
const syncText = document.getElementById('tag-preview-sync-text');
if (syncText) syncText.textContent = `Sync to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`;
syncLabel.classList.remove('hidden');
}
let html = '<table class="tag-preview-table"><thead><tr>';
html += '<th>Field</th><th>Current File Tag</th><th></th><th>DB Value</th>';
html += '</tr></thead><tbody>';
@ -34974,18 +34990,25 @@ async function executeWriteTags() {
}
const embedCover = document.getElementById('tag-preview-embed-cover')?.checked ?? true;
const syncToServer = document.getElementById('tag-preview-sync-server')?.checked && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome';
try {
const response = await fetch(`/api/library/track/${_tagPreviewTrackId}/write-tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embed_cover: embedCover })
body: JSON.stringify({ embed_cover: embedCover, sync_to_server: syncToServer })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
const fieldCount = (result.written_fields || []).length;
showToast(`Tags written successfully (${fieldCount} fields)`, 'success');
let msg = `Tags written successfully (${fieldCount} fields)`;
if (result.server_sync) {
const ss = result.server_sync;
if (ss.synced > 0) msg += ` — synced to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`;
else if (ss.failed > 0) msg += ` — server sync failed`;
}
showToast(msg, 'success');
closeTagPreviewModal();
} catch (error) {
@ -35008,24 +35031,34 @@ async function writeAlbumTags(albumId) {
return;
}
if (!confirm(`Write DB metadata to file tags for all ${tracks.length} tracks in "${album.title}"?`)) return;
await _startBatchWriteTags(tracks.map(t => t.id), true);
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);
}
async function batchWriteTagsSelected() {
const trackIds = Array.from(artistDetailPageState.selectedTracks);
if (trackIds.length === 0) return;
if (!confirm(`Write DB metadata to file tags for ${trackIds.length} selected track(s)?`)) return;
await _startBatchWriteTags(trackIds, true);
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);
}
async function _startBatchWriteTags(trackIds, embedCover) {
async function _startBatchWriteTags(trackIds, embedCover, syncToServer = false) {
try {
const response = await fetch('/api/library/tracks/write-tags-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds, embed_cover: embedCover })
body: JSON.stringify({ track_ids: trackIds, embed_cover: embedCover, sync_to_server: syncToServer })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
@ -35049,12 +35082,25 @@ function _pollBatchWriteTagsStatus() {
const state = await response.json();
if (state.status === 'running') {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Writing tags: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info');
if (state.sync_phase === 'syncing') {
const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server;
showToast(`Syncing to ${serverName}...`, 'info');
} else {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Writing tags: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info');
}
_batchWriteTagsPollTimer = setTimeout(poll, 1000);
} else if (state.status === 'done') {
const msg = `Tags written: ${state.written} succeeded, ${state.failed} failed`;
showToast(msg, state.failed > 0 ? 'warning' : 'success');
let msg = `Tags written: ${state.written} succeeded, ${state.failed} failed`;
if (state.sync_phase === 'done') {
const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server;
if (state.sync_synced > 0 && state.sync_failed === 0) {
msg += ` — synced to ${serverName}`;
} else if (state.sync_failed > 0) {
msg += `${serverName} sync: ${state.sync_synced} synced, ${state.sync_failed} failed`;
}
}
showToast(msg, state.failed > 0 || state.sync_failed > 0 ? 'warning' : 'success');
_batchWriteTagsPollTimer = null;
}
} catch (error) {

Loading…
Cancel
Save