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:
parent
890d47928d
commit
2dc72a39b7
4 changed files with 216 additions and 5 deletions
|
|
@ -146,6 +146,7 @@ class NavidromeClient:
|
||||||
self.base_url: Optional[str] = None
|
self.base_url: Optional[str] = None
|
||||||
self.username: Optional[str] = None
|
self.username: Optional[str] = None
|
||||||
self.password: Optional[str] = None
|
self.password: Optional[str] = None
|
||||||
|
self.music_folder_id: Optional[str] = None
|
||||||
self._connection_attempted = False
|
self._connection_attempted = False
|
||||||
self._is_connecting = False
|
self._is_connecting = False
|
||||||
|
|
||||||
|
|
@ -161,6 +162,61 @@ class NavidromeClient:
|
||||||
"""Set callback function for progress updates"""
|
"""Set callback function for progress updates"""
|
||||||
self._progress_callback = callback
|
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:
|
def ensure_connection(self) -> bool:
|
||||||
"""Ensure connection to Navidrome server with lazy initialization."""
|
"""Ensure connection to Navidrome server with lazy initialization."""
|
||||||
if self._connection_attempted:
|
if self._connection_attempted:
|
||||||
|
|
@ -199,6 +255,21 @@ class NavidromeClient:
|
||||||
if response and response.get('status') == 'ok':
|
if response and response.get('status') == 'ok':
|
||||||
server_version = response.get('version', 'Unknown')
|
server_version = response.get('version', 'Unknown')
|
||||||
logger.info(f"Successfully connected to Navidrome server version: {server_version}")
|
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:
|
else:
|
||||||
logger.error(f"Failed to connect to Navidrome server at {self.base_url}/rest/ping — check URL and network connectivity")
|
logger.error(f"Failed to connect to Navidrome server at {self.base_url}/rest/ping — check URL and network connectivity")
|
||||||
self.base_url = None
|
self.base_url = None
|
||||||
|
|
@ -284,7 +355,10 @@ class NavidromeClient:
|
||||||
if self._progress_callback:
|
if self._progress_callback:
|
||||||
self._progress_callback("Fetching artists from Navidrome...")
|
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:
|
if not response:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -371,11 +445,14 @@ class NavidromeClient:
|
||||||
offset = 0
|
offset = 0
|
||||||
|
|
||||||
while len(albums) < limit:
|
while len(albums) < limit:
|
||||||
response = self._make_request('getAlbumList2', {
|
params = {
|
||||||
'type': 'newest',
|
'type': 'newest',
|
||||||
'size': page_size,
|
'size': page_size,
|
||||||
'offset': offset
|
'offset': offset
|
||||||
})
|
}
|
||||||
|
if self.music_folder_id:
|
||||||
|
params['musicFolderId'] = self.music_folder_id
|
||||||
|
response = self._make_request('getAlbumList2', params)
|
||||||
if not response:
|
if not response:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -819,12 +896,15 @@ class NavidromeClient:
|
||||||
try:
|
try:
|
||||||
# Use Subsonic search3 API for music search
|
# Use Subsonic search3 API for music search
|
||||||
query = f"{artist} {title}".strip()
|
query = f"{artist} {title}".strip()
|
||||||
response = self._make_request('search3', {
|
params = {
|
||||||
'query': query,
|
'query': query,
|
||||||
'songCount': limit,
|
'songCount': limit,
|
||||||
'artistCount': 0,
|
'artistCount': 0,
|
||||||
'albumCount': 0
|
'albumCount': 0
|
||||||
})
|
}
|
||||||
|
if self.music_folder_id:
|
||||||
|
params['musicFolderId'] = self.music_folder_id
|
||||||
|
response = self._make_request('search3', params)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -2531,6 +2531,7 @@ def handle_settings():
|
||||||
spotify_client.reload_config()
|
spotify_client.reload_config()
|
||||||
plex_client.server = None
|
plex_client.server = None
|
||||||
jellyfin_client.reload_config()
|
jellyfin_client.reload_config()
|
||||||
|
navidrome_client.reload_config()
|
||||||
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
|
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
|
||||||
soulseek_client.reload_settings()
|
soulseek_client.reload_settings()
|
||||||
# FIX: Re-instantiate the global tidal_client to pick up new 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}")
|
logger.error(f"Error setting Jellyfin music library: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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 ==
|
# == QUALITY PROFILE API ==
|
||||||
# ===============================
|
# ===============================
|
||||||
|
|
|
||||||
|
|
@ -3012,6 +3012,14 @@
|
||||||
<label>Password:</label>
|
<label>Password:</label>
|
||||||
<input type="password" id="navidrome-password" placeholder="Password">
|
<input type="password" id="navidrome-password" placeholder="Password">
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button class="detect-button"
|
<button class="detect-button"
|
||||||
onclick="autoDetectNavidrome()">Auto-detect</button>
|
onclick="autoDetectNavidrome()">Auto-detect</button>
|
||||||
|
|
|
||||||
|
|
@ -1808,6 +1808,11 @@ async function loadSettingsData() {
|
||||||
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
|
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Navidrome music folders if Navidrome is the active server
|
||||||
|
if (activeServer === 'navidrome') {
|
||||||
|
loadNavidromeMusicFolders();
|
||||||
|
}
|
||||||
|
|
||||||
// Populate Soulseek settings
|
// Populate Soulseek settings
|
||||||
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
|
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
|
||||||
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
|
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
|
||||||
|
|
@ -1968,6 +1973,11 @@ function toggleServer(serverType) {
|
||||||
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
|
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Navidrome music folders when switching to Navidrome
|
||||||
|
if (serverType === 'navidrome') {
|
||||||
|
loadNavidromeMusicFolders();
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-save after server toggle change
|
// Auto-save after server toggle change
|
||||||
debouncedAutoSaveSettings();
|
debouncedAutoSaveSettings();
|
||||||
}
|
}
|
||||||
|
|
@ -2589,6 +2599,8 @@ async function testConnection(service) {
|
||||||
loadPlexMusicLibraries();
|
loadPlexMusicLibraries();
|
||||||
} else if (service === 'jellyfin') {
|
} else if (service === 'jellyfin') {
|
||||||
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
|
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
|
||||||
|
} else if (service === 'navidrome') {
|
||||||
|
loadNavidromeMusicFolders();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(`${service} connection failed: ${result.error}`, 'error');
|
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 ==
|
// == DISCOVER PAGE ==
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue