Add server sync option to enhanced library write-tags flow
This commit is contained in:
parent
729f42f1a4
commit
cc4502e5f8
3 changed files with 181 additions and 12 deletions
119
web_server.py
119
web_server.py
|
|
@ -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…
Reference in a new issue