user-editable hifi instances

pull/393/head
elmerohueso 4 weeks ago
parent 69e909e687
commit 6ae1cb471e

@ -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]:

@ -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()

@ -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/<path:url>', 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
# ===================================================================

@ -4622,8 +4622,13 @@
</div>
<div class="form-group">
<label>API Instances:</label>
<div id="hifi-instances-list" style="margin-bottom: 8px;"></div>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<input type="url" id="hifi-new-instance" class="form-input" placeholder="https://example.com" style="flex:1;">
<button class="test-button" onclick="addHiFiInstance()">Add</button>
</div>
<button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn" style="margin-bottom: 8px;">Check All Instances</button>
<div id="hifi-instances-panel" style="display: none;"></div>
<div id="hifi-instances-status-panel" style="display: none;"></div>
</div>
</div>

@ -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 = '<div style="color: rgba(255,255,255,0.4); font-size: 0.85em;">No instances configured.</div>';
return;
}
listEl.innerHTML = data.instances.map((inst, i) => {
const enabledClass = inst.enabled ? '' : 'opacity:0.4;';
const checkHtml = inst.enabled
? `<span style="color:#4caf50;cursor:pointer;" onclick="toggleHiFiInstance('${escapeHtml(inst.url)}')" title="Click to disable">&#x2714;</span>`
: `<span style="color:#666;cursor:pointer;" onclick="toggleHiFiInstance('${escapeHtml(inst.url)}')" title="Click to enable">&#x2718;</span>`;
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.82em;${enabledClass}">
<span style="color:rgba(255,255,255,0.4);cursor:default;user-select:none;">&#x2630;</span>
<span style="flex:1;color:rgba(255,255,255,0.7);font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(inst.url)}</span>
${checkHtml}
<span style="color:#f44336;cursor:pointer;font-size:0.9em;" onclick="removeHiFiInstance('${escapeHtml(inst.url)}')" title="Remove instance">&#x2716;</span>
</div>`;
}).join('');
} catch (e) {
listEl.innerHTML = `<div style="color:#f44336;font-size:0.85em;">Error loading instances: ${escapeHtml(e.message)}</div>`;
}
}
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';

Loading…
Cancel
Save