diff --git a/core/hifi_client.py b/core/hifi_client.py index 6da7ddf0..f288501e 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -91,14 +91,12 @@ class HiFiClient: self.download_path = Path(download_path) self.download_path.mkdir(parents=True, exist_ok=True) - # API instance management - self._instances = list(DEFAULT_INSTANCES) - if base_url: - # User-provided instance gets top priority - self._instances.insert(0, base_url.rstrip('/')) + # API instance management — loaded from database + self._instances = [] + self._instance_lock = threading.Lock() + self._load_instances_from_db() self._current_instance = self._instances[0] if self._instances else None - self._instance_lock = threading.Lock() # HTTP session with retry-friendly settings self.session = http_requests.Session() @@ -126,6 +124,33 @@ class HiFiClient: """Set a callback function to check for system shutdown.""" self.shutdown_check = check_callable + def _load_instances_from_db(self): + """Load instances from the database, seeding defaults if empty.""" + try: + from database.music_database import get_database + db = get_database() + db.seed_hifi_instances(DEFAULT_INSTANCES) + rows = db.get_hifi_instances() + urls = [r['url'] for r in rows if r['enabled']] + if urls: + self._instances = urls + else: + self._instances = list(DEFAULT_INSTANCES) + except Exception as e: + logger.warning(f"Failed to load HiFi instances from DB, using defaults: {e}") + self._instances = list(DEFAULT_INSTANCES) + + def reload_instances(self): + """Reload instances from the database (called after settings change).""" + with self._instance_lock: + old_current = self._current_instance + self._load_instances_from_db() + self._current_instance = self._instances[0] if self._instances else None + if self._current_instance != old_current: + logger.info(f"HiFi instances reloaded, active: {self._current_instance}") + else: + logger.info("HiFi instances reloaded") + # ===================== Instance Management ===================== def _get_instance(self) -> Optional[str]: diff --git a/database/music_database.py b/database/music_database.py index d4f68f0a..7b5b265d 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -723,6 +723,17 @@ class MusicDatabase: except Exception: pass + # HiFi API instances table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS hifi_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() logger.info("Database initialized successfully") @@ -11594,6 +11605,88 @@ class MusicDatabase: logger.error(f"Error getting issue counts: {e}") return {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0} + # ===================== HiFi Instances ===================== + + def get_hifi_instances(self) -> List[Dict[str, Any]]: + """Get all enabled HiFi instances ordered by priority.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT url, priority, enabled FROM hifi_instances WHERE enabled = 1 ORDER BY priority ASC, id ASC") + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting HiFi instances: {e}") + return [] + + def get_all_hifi_instances(self) -> List[Dict[str, Any]]: + """Get all HiFi instances (including disabled) ordered by priority.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT url, priority, enabled FROM hifi_instances ORDER BY priority ASC, id ASC") + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting all HiFi instances: {e}") + return [] + + def add_hifi_instance(self, url: str, priority: int = 0) -> bool: + """Add a new HiFi instance.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT OR IGNORE INTO hifi_instances (url, priority, enabled) VALUES (?, ?, 1)", + (url, priority) + ) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error adding HiFi instance: {e}") + return False + + def remove_hifi_instance(self, url: str) -> bool: + """Remove a HiFi instance.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM hifi_instances WHERE url = ?", (url,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error removing HiFi instance: {e}") + return False + + def reorder_hifi_instances(self, urls: List[str]) -> bool: + """Update priorities based on the given URL order.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + for i, url in enumerate(urls): + cursor.execute("UPDATE hifi_instances SET priority = ? WHERE url = ?", (i, url)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error reordering HiFi instances: {e}") + return False + + def seed_hifi_instances(self, default_urls: List[str]) -> None: + """Insert default instances if the table is empty.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) as cnt FROM hifi_instances") + count = cursor.fetchone()['cnt'] + if count == 0: + for i, url in enumerate(default_urls): + cursor.execute( + "INSERT OR IGNORE INTO hifi_instances (url, priority, enabled) VALUES (?, ?, 1)", + (url, i) + ) + conn.commit() + logger.info(f"Seeded {len(default_urls)} default HiFi instances") + except Exception as e: + logger.error(f"Error seeding HiFi instances: {e}") + # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _database_lock = threading.Lock() diff --git a/web_server.py b/web_server.py index f2d93f2f..c6d58652 100644 --- a/web_server.py +++ b/web_server.py @@ -24700,6 +24700,89 @@ def hifi_instances(): return jsonify({'error': str(e)}), 500 +@app.route('/api/hifi/instances', methods=['POST']) +def hifi_add_instance(): + """Add a new HiFi API instance.""" + try: + data = request.get_json() or {} + url = data.get('url', '').strip().rstrip('/') + if not url: + return jsonify({'success': False, 'error': 'URL is required'}), 400 + if not url.startswith(('http://', 'https://')): + return jsonify({'success': False, 'error': 'URL must start with http:// or https://'}), 400 + from database.music_database import get_database + db = get_database() + # Get current count to assign next priority + existing = db.get_all_hifi_instances() + priority = len(existing) + added = db.add_hifi_instance(url, priority) + if not added: + return jsonify({'success': False, 'error': 'Instance already exists'}), 400 + # Reload the client + if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi: + soulseek_client.hifi.reload_instances() + return jsonify({'success': True, 'url': url}) + except Exception as e: + logger.error(f"Error adding HiFi instance: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances/', methods=['DELETE']) +def hifi_remove_instance(url): + """Remove a HiFi API instance.""" + try: + url = url.strip().rstrip('/') + if not url: + return jsonify({'success': False, 'error': 'URL is required'}), 400 + from database.music_database import get_database + db = get_database() + removed = db.remove_hifi_instance(url) + if not removed: + return jsonify({'success': False, 'error': 'Instance not found'}), 404 + # Reload the client + if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi: + soulseek_client.hifi.reload_instances() + return jsonify({'success': True, 'url': url}) + except Exception as e: + logger.error(f"Error removing HiFi instance: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances/reorder', methods=['POST']) +def hifi_reorder_instances(): + """Reorder HiFi API instances.""" + try: + data = request.get_json() or {} + urls = data.get('urls', []) + if not urls: + return jsonify({'success': False, 'error': 'URL list is required'}), 400 + from database.music_database import get_database + db = get_database() + db.reorder_hifi_instances(urls) + # Reload the client + 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 reordering HiFi instances: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances/list', methods=['GET']) +def hifi_list_instances(): + """Get editable list of HiFi API instances.""" + try: + from database.music_database import get_database + from core.hifi_client import DEFAULT_INSTANCES + db = get_database() + db.seed_hifi_instances(DEFAULT_INSTANCES) + instances = db.get_all_hifi_instances() + return jsonify({'instances': instances}) + except Exception as e: + logger.error(f"Error listing HiFi instances: {e}") + return jsonify({'error': str(e)}), 500 + + # =================================================================== # DEEZER DOWNLOAD ENDPOINTS # =================================================================== diff --git a/webui/index.html b/webui/index.html index 7bcab176..8721bde0 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4622,8 +4622,13 @@
+
+
+ + +
- +
diff --git a/webui/static/settings.js b/webui/static/settings.js index fb47a9ec..724293c3 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -797,6 +797,7 @@ async function loadSettingsData() { document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false; document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless'; document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false; + loadHiFiInstances(); document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac'; document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false; document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || ''; @@ -3277,8 +3278,78 @@ async function testLidarrConnection() { } } +async function loadHiFiInstances() { + const listEl = document.getElementById('hifi-instances-list'); + if (!listEl) return; + try { + const resp = await fetch('/api/hifi/instances/list'); + const data = await resp.json(); + if (!data.instances || data.instances.length === 0) { + listEl.innerHTML = '
No instances configured.
'; + return; + } + listEl.innerHTML = data.instances.map((inst, i) => { + const enabledClass = inst.enabled ? '' : 'opacity:0.4;'; + const checkHtml = inst.enabled + ? `` + : ``; + return `
+ + ${escapeHtml(inst.url)} + ${checkHtml} + +
`; + }).join(''); + } catch (e) { + listEl.innerHTML = `
Error loading instances: ${escapeHtml(e.message)}
`; + } +} + +async function addHiFiInstance() { + const input = document.getElementById('hifi-new-instance'); + if (!input) return; + const url = input.value.trim(); + if (!url) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + alert('URL must start with http:// or https://'); + return; + } + try { + const resp = await fetch('/api/hifi/instances', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const data = await resp.json(); + if (data.success) { + input.value = ''; + loadHiFiInstances(); + } else { + alert(data.error || 'Failed to add instance'); + } + } catch (e) { + alert(`Error: ${e.message}`); + } +} + +async function removeHiFiInstance(url) { + try { + const resp = await fetch(`/api/hifi/instances/${encodeURIComponent(url)}`, { + method: 'DELETE' + }); + const data = await resp.json(); + if (data.success) { + loadHiFiInstances(); + } else { + alert(data.error || 'Failed to remove instance'); + } + } catch (e) { + alert(`Error: ${e.message}`); + } +} + async function checkHiFiInstances() { - const panel = document.getElementById('hifi-instances-panel'); + const panel = document.getElementById('hifi-instances-status-panel'); const btn = document.getElementById('hifi-instances-check-btn'); if (!panel) return; panel.style.display = 'block';