Add music library selection for Navidrome

SoulSync was importing from all Navidrome libraries regardless of user access restrictions. Added a "Music Library"   dropdown in Navidrome settings that lets users scope imports to a specific music folder. Uses the Subsonic musicFolderId       parameter on artist, album, and search API calls. Selecting "All Libraries" reverts to the previous behavior.
This commit is contained in:
Broque Thomas 2026-02-27 09:37:58 -08:00
parent 890d47928d
commit 2dc72a39b7
4 changed files with 216 additions and 5 deletions

View file

@ -146,6 +146,7 @@ class NavidromeClient:
self.base_url: Optional[str] = None
self.username: Optional[str] = None
self.password: Optional[str] = None
self.music_folder_id: Optional[str] = None
self._connection_attempted = False
self._is_connecting = False
@ -161,6 +162,61 @@ class NavidromeClient:
"""Set callback function for progress updates"""
self._progress_callback = callback
def reload_config(self):
"""Reset connection state so next ensure_connection() re-reads config."""
self.base_url = None
self.username = None
self.password = None
self.music_folder_id = None
self._connection_attempted = False
self._artist_cache.clear()
self._album_cache.clear()
self._track_cache.clear()
logger.info("🔄 Navidrome client config reset — will reconnect with new settings")
def get_music_folders(self) -> list:
"""Get available music folders from Navidrome."""
if not self.ensure_connection():
return []
try:
response = self._make_request('getMusicFolders')
if not response:
return []
folders_data = response.get('musicFolders', {})
folder_list = folders_data.get('musicFolder', [])
if isinstance(folder_list, dict):
folder_list = [folder_list]
return [{'title': f.get('name', 'Unknown'), 'key': str(f.get('id', ''))} for f in folder_list]
except Exception as e:
logger.error(f"Error getting music folders: {e}")
return []
def set_music_folder_by_name(self, folder_name: str) -> bool:
"""Set the active music folder by name."""
try:
folders = self.get_music_folders()
for folder in folders:
if folder['title'] == folder_name:
self.music_folder_id = folder['key']
logger.info(f"Set music folder to: {folder_name} (ID: {self.music_folder_id})")
from database.music_database import MusicDatabase
db = MusicDatabase()
db.set_preference('navidrome_music_folder', folder_name)
return True
# If folder_name is empty, clear the selection
if not folder_name:
self.music_folder_id = None
from database.music_database import MusicDatabase
db = MusicDatabase()
db.set_preference('navidrome_music_folder', '')
logger.info("Cleared music folder selection — will use all libraries")
return True
logger.warning(f"Music folder '{folder_name}' not found")
return False
except Exception as e:
logger.error(f"Error setting music folder: {e}")
return False
def ensure_connection(self) -> bool:
"""Ensure connection to Navidrome server with lazy initialization."""
if self._connection_attempted:
@ -199,6 +255,21 @@ class NavidromeClient:
if response and response.get('status') == 'ok':
server_version = response.get('version', 'Unknown')
logger.info(f"Successfully connected to Navidrome server version: {server_version}")
# Restore saved music folder preference
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
saved_folder = db.get_preference('navidrome_music_folder')
if saved_folder:
folders = self.get_music_folders()
for folder in folders:
if folder['title'] == saved_folder:
self.music_folder_id = folder['key']
logger.info(f"Restored music folder preference: {saved_folder} (ID: {self.music_folder_id})")
break
except Exception as e:
logger.warning(f"Could not restore music folder preference: {e}")
else:
logger.error(f"Failed to connect to Navidrome server at {self.base_url}/rest/ping — check URL and network connectivity")
self.base_url = None
@ -284,7 +355,10 @@ class NavidromeClient:
if self._progress_callback:
self._progress_callback("Fetching artists from Navidrome...")
response = self._make_request('getArtists')
params = {}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('getArtists', params if params else None)
if not response:
return []
@ -371,11 +445,14 @@ class NavidromeClient:
offset = 0
while len(albums) < limit:
response = self._make_request('getAlbumList2', {
params = {
'type': 'newest',
'size': page_size,
'offset': offset
})
}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('getAlbumList2', params)
if not response:
break
@ -819,12 +896,15 @@ class NavidromeClient:
try:
# Use Subsonic search3 API for music search
query = f"{artist} {title}".strip()
response = self._make_request('search3', {
params = {
'query': query,
'songCount': limit,
'artistCount': 0,
'albumCount': 0
})
}
if self.music_folder_id:
params['musicFolderId'] = self.music_folder_id
response = self._make_request('search3', params)
if not response:
return []

View file

@ -2531,6 +2531,7 @@ def handle_settings():
spotify_client.reload_config()
plex_client.server = None
jellyfin_client.reload_config()
navidrome_client.reload_config()
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
soulseek_client.reload_settings()
# FIX: Re-instantiate the global tidal_client to pick up new settings
@ -3074,6 +3075,56 @@ def select_jellyfin_music_library():
logger.error(f"Error setting Jellyfin music library: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/navidrome/music-folders', methods=['GET'])
def get_navidrome_music_folders():
"""Get list of available music folders from Navidrome"""
try:
folders = navidrome_client.get_music_folders()
from database.music_database import MusicDatabase
db = MusicDatabase()
selected_folder = db.get_preference('navidrome_music_folder')
current_folder = None
if navidrome_client.music_folder_id:
for f in folders:
if f['key'] == navidrome_client.music_folder_id:
current_folder = f['title']
break
return jsonify({
"success": True,
"folders": folders,
"selected": selected_folder,
"current": current_folder
})
except Exception as e:
logger.error(f"Error getting Navidrome music folders: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/navidrome/select-music-folder', methods=['POST'])
def select_navidrome_music_folder():
"""Set the active Navidrome music folder"""
try:
data = request.get_json()
folder_name = data.get('folder_name', '')
success = navidrome_client.set_music_folder_by_name(folder_name)
if success:
if folder_name:
add_activity_item("📚", "Library Selected", f"Navidrome music folder set to: {folder_name}", "Now")
return jsonify({"success": True, "message": f"Music folder set to: {folder_name}"})
else:
add_activity_item("📚", "Library Selection Cleared", "Navidrome will use all libraries", "Now")
return jsonify({"success": True, "message": "Music folder selection cleared — using all libraries"})
else:
return jsonify({"success": False, "error": f"Folder '{folder_name}' not found"}), 404
except Exception as e:
logger.error(f"Error setting Navidrome music folder: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == QUALITY PROFILE API ==
# ===============================

View file

@ -3012,6 +3012,14 @@
<label>Password:</label>
<input type="password" id="navidrome-password" placeholder="Password">
</div>
<div class="form-group" id="navidrome-folder-selector-container"
style="display: none;">
<label>Music Library</label>
<select id="navidrome-music-folder" onchange="selectNavidromeMusicFolder()">
<option value="">All Libraries</option>
</select>
<small>Select which music library to import from</small>
</div>
<div class="form-actions">
<button class="detect-button"
onclick="autoDetectNavidrome()">Auto-detect</button>

View file

@ -1808,6 +1808,11 @@ async function loadSettingsData() {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
}
// Load Navidrome music folders if Navidrome is the active server
if (activeServer === 'navidrome') {
loadNavidromeMusicFolders();
}
// Populate Soulseek settings
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
@ -1968,6 +1973,11 @@ function toggleServer(serverType) {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
}
// Load Navidrome music folders when switching to Navidrome
if (serverType === 'navidrome') {
loadNavidromeMusicFolders();
}
// Auto-save after server toggle change
debouncedAutoSaveSettings();
}
@ -2589,6 +2599,8 @@ async function testConnection(service) {
loadPlexMusicLibraries();
} else if (service === 'jellyfin') {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
} else if (service === 'navidrome') {
loadNavidromeMusicFolders();
}
} else {
showToast(`${service} connection failed: ${result.error}`, 'error');
@ -32712,6 +32724,66 @@ async function selectJellyfinLibrary() {
}
}
// ============ Navidrome Music Folder Selection ============
async function loadNavidromeMusicFolders() {
try {
const response = await fetch('/api/navidrome/music-folders');
const data = await response.json();
if (data.success && data.folders && data.folders.length > 0) {
const selector = document.getElementById('navidrome-music-folder');
const container = document.getElementById('navidrome-folder-selector-container');
selector.innerHTML = '<option value="">All Libraries</option>';
data.folders.forEach(folder => {
const option = document.createElement('option');
option.value = folder.title;
option.textContent = folder.title;
if (folder.title === data.current || folder.title === data.selected) {
option.selected = true;
}
selector.appendChild(option);
});
container.style.display = 'block';
} else {
document.getElementById('navidrome-folder-selector-container').style.display = 'none';
}
} catch (error) {
console.error('Error loading Navidrome music folders:', error);
document.getElementById('navidrome-folder-selector-container').style.display = 'none';
}
}
async function selectNavidromeMusicFolder() {
const selector = document.getElementById('navidrome-music-folder');
const selectedFolder = selector.value;
try {
const response = await fetch('/api/navidrome/select-music-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_name: selectedFolder })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
} else {
console.error('Failed to set music folder:', data.error);
showToast(`Failed to set music folder: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error selecting Navidrome music folder:', error);
showToast('Error selecting music folder. Please try again.', 'error');
}
}
// ============================================
// == DISCOVER PAGE ==
// ============================================