diff --git a/database/music_database.py b/database/music_database.py
index 7b5b265d..ddfd9f65 100644
--- a/database/music_database.py
+++ b/database/music_database.py
@@ -11656,6 +11656,18 @@ class MusicDatabase:
logger.error(f"Error removing HiFi instance: {e}")
return False
+ def toggle_hifi_instance(self, url: str, enabled: bool) -> bool:
+ """Enable or disable a HiFi instance."""
+ try:
+ conn = self._get_connection()
+ cursor = conn.cursor()
+ cursor.execute("UPDATE hifi_instances SET enabled = ? WHERE url = ?", (1 if enabled else 0, url))
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception as e:
+ logger.error(f"Error toggling HiFi instance: {e}")
+ return False
+
def reorder_hifi_instances(self, urls: List[str]) -> bool:
"""Update priorities based on the given URL order."""
try:
diff --git a/web_server.py b/web_server.py
index e91cb7ed..1f678d08 100644
--- a/web_server.py
+++ b/web_server.py
@@ -24750,6 +24750,27 @@ def hifi_remove_instance(url):
return jsonify({'success': False, 'error': str(e)}), 500
+@app.route('/api/hifi/instances/toggle', methods=['POST'])
+@admin_only
+def hifi_toggle_instance():
+ """Toggle enabled state of a HiFi API instance."""
+ try:
+ data = request.get_json() or {}
+ url = data.get('url', '').strip().rstrip('/')
+ enabled = data.get('enabled', True)
+ if not url:
+ return jsonify({'success': False, 'error': 'URL is required'}), 400
+ from database.music_database import get_database
+ db = get_database()
+ db.toggle_hifi_instance(url, enabled)
+ if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi:
+ soulseek_client.hifi.reload_instances()
+ return jsonify({'success': True})
+ except Exception as e:
+ logger.error(f"Error toggling HiFi instance: {e}")
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
@app.route('/api/hifi/instances/reorder', methods=['POST'])
@admin_only
def hifi_reorder_instances():
diff --git a/webui/static/settings.js b/webui/static/settings.js
index 724293c3..4f916223 100644
--- a/webui/static/settings.js
+++ b/webui/static/settings.js
@@ -3289,22 +3289,102 @@ async function loadHiFiInstances() {
return;
}
listEl.innerHTML = data.instances.map((inst, i) => {
- const enabledClass = inst.enabled ? '' : 'opacity:0.4;';
+ const enabledClass = inst.enabled ? '' : 'hifi-instance-disabled';
const checkHtml = inst.enabled
- ? `✔`
- : `✘`;
- return `
-
☰
-
${escapeHtml(inst.url)}
+ ? `
✔`
+ : `
✘`;
+ return `
+ ☰
+ ${escapeHtml(inst.url)}
${checkHtml}
- ✖
+ ✖
`;
}).join('');
+ _initHiFiDragDrop();
} catch (e) {
listEl.innerHTML = `
Error loading instances: ${escapeHtml(e.message)}
`;
}
}
+function _initHiFiDragDrop() {
+ const listEl = document.getElementById('hifi-instances-list');
+ if (!listEl) return;
+ let dragIdx = null;
+
+ listEl.querySelectorAll('.hifi-instance-item').forEach((item, idx) => {
+ item.addEventListener('dragstart', (e) => {
+ dragIdx = idx;
+ item.classList.add('dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ });
+ item.addEventListener('dragend', () => {
+ item.classList.remove('dragging');
+ dragIdx = null;
+ listEl.querySelectorAll('.hifi-instance-item').forEach(i => i.classList.remove('drag-over'));
+ });
+ item.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ item.classList.add('drag-over');
+ });
+ item.addEventListener('dragleave', () => {
+ item.classList.remove('drag-over');
+ });
+ item.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ item.classList.remove('drag-over');
+ if (dragIdx === null) return;
+ const items = [...listEl.querySelectorAll('.hifi-instance-item')];
+ const dragged = items[dragIdx];
+ if (dragIdx !== idx) {
+ if (dragIdx < idx) {
+ item.after(dragged);
+ } else {
+ item.before(dragged);
+ }
+ const urls = [...listEl.querySelectorAll('.hifi-instance-item')].map(el => el.dataset.url);
+ await _saveHiFiInstanceOrder(urls);
+ }
+ });
+ });
+}
+
+async function _saveHiFiInstanceOrder(urls) {
+ try {
+ await fetch('/api/hifi/instances/reorder', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ urls })
+ });
+ } catch (e) {
+ console.error('Failed to save HiFi instance order:', e);
+ }
+}
+
+async function toggleHiFiInstance(url) {
+ const listEl = document.getElementById('hifi-instances-list');
+ if (!listEl) return;
+ const item = listEl.querySelector(`.hifi-instance-item[data-url="${url}"]`);
+ const toggle = item?.querySelector('.hifi-instance-toggle');
+ const currentlyEnabled = toggle?.classList.contains('on');
+ const newEnabled = !currentlyEnabled;
+ try {
+ const resp = await fetch('/api/hifi/instances/toggle', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url, enabled: newEnabled })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ loadHiFiInstances();
+ } else {
+ alert(data.error || 'Failed to toggle instance');
+ }
+ } catch (e) {
+ alert(`Error: ${e.message}`);
+ }
+}
+
async function addHiFiInstance() {
const input = document.getElementById('hifi-new-instance');
if (!input) return;
diff --git a/webui/static/style.css b/webui/static/style.css
index 054e9d94..a25df5ed 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -60262,3 +60262,80 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
#artist-detail-page .release-card.album-card .mb-card-icon:hover {
opacity: 1;
}
+
+/* ── HiFi API instances list (drag and drop) ── */
+#hifi-instances-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.hifi-instance-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 8px;
+ font-size: 0.82em;
+ user-select: none;
+ transition: all 0.2s;
+ cursor: grab;
+}
+.hifi-instance-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+.hifi-instance-item.dragging {
+ opacity: 0.4;
+ border-color: rgb(var(--accent-rgb));
+}
+.hifi-instance-item.drag-over {
+ border-color: rgb(var(--accent-rgb));
+ background: rgba(var(--accent-rgb), 0.08);
+}
+.hifi-instance-item.disabled {
+ opacity: 0.4;
+}
+.hifi-instance-grip {
+ color: rgba(255, 255, 255, 0.3);
+ cursor: grab;
+ flex-shrink: 0;
+ font-size: 1em;
+}
+.hifi-instance-url {
+ flex: 1;
+ color: rgba(255, 255, 255, 0.7);
+ font-family: monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.hifi-instance-toggle {
+ flex-shrink: 0;
+ cursor: pointer;
+ font-size: 0.9em;
+ transition: color 0.15s;
+}
+.hifi-instance-toggle.on {
+ color: #4caf50;
+}
+.hifi-instance-toggle.on:hover {
+ color: #66bb6a;
+}
+.hifi-instance-toggle.off {
+ color: #666;
+}
+.hifi-instance-toggle.off:hover {
+ color: #999;
+}
+.hifi-instance-remove {
+ flex-shrink: 0;
+ color: #f44336;
+ cursor: pointer;
+ font-size: 0.9em;
+ transition: color 0.15s;
+}
+.hifi-instance-remove:hover {
+ color: #ef5350;
+}