fix: multiple UI and integration fixes

1. Credential storage fallback - when keyring (KWallet) unavailable,
   falls back to obfuscated file in config directory

2. Heart icon always visible - shows outlined when not favorite,
   filled when favorite, with hover states

3. Player favorite state - checks favorite status when track plays
   and updates the heart icon in NowPlayingBar

4. Song.link ISRC lookup - uses Spotify/Deezer search URLs with ISRC
   since Qobuz isn't directly supported by Odesli
This commit is contained in:
vicrodh committed 2026-01-09 19:00:12 -06:00
1 parent 657682fc5e
commit 1ccb78ef52
5 files changed
+279 -93

No files matched your search

+9 -4
View File
@@ -5,17 +5,22 @@ use tauri::State;
use crate::share::{ShareError, SongLinkResponse};
use crate::AppState;
/// Get song.link URL for a track using Qobuz track ID
/// Get song.link URL for a track using ISRC
/// Qobuz isn't supported by Odesli, so we use ISRC to find the track
#[tauri::command]
pub async fn share_track_songlink(
track_id: u64,
isrc: String,
state: State<'_, AppState>,
) -> Result<SongLinkResponse, String> {
log::info!("Command: share_track_songlink track_id={}", track_id);
if isrc.is_empty() {
return Err(ShareError::MissingIsrc.to_string());
}
log::info!("Command: share_track_songlink ISRC={}", isrc);
state
.songlink
.get_by_track_id(&track_id.to_string())
.get_by_isrc(&isrc)
.await
.map_err(|e| e.to_string())
}
+165 -63
View File
@@ -1,15 +1,23 @@
//! Secure credential storage using system keyring
//! Secure credential storage with fallback
//!
//! Uses the system's secure credential storage:
//! Tries system keyring first, falls back to file-based storage:
//! - Linux: Secret Service (GNOME Keyring, KWallet via D-Bus)
//! - macOS: Keychain
//! - Windows: Credential Manager
//! - Fallback: Obfuscated file in config directory
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use keyring::Entry;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const SERVICE_NAME: &str = "qbz-nix";
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
// Simple XOR key for obfuscation (not encryption, just to avoid plain text)
const OBFUSCATION_KEY: &[u8] = b"QbzNixAudiophile2024";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QobuzCredentials {
@@ -17,89 +25,183 @@ pub struct QobuzCredentials {
pub password: String,
}
/// Save Qobuz credentials to system keyring
/// Get the fallback credentials file path
fn get_fallback_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("qbz-nix").join(FALLBACK_FILE_NAME))
}
/// Simple XOR obfuscation (not secure, but avoids plain text)
fn obfuscate(data: &[u8]) -> Vec<u8> {
data.iter()
.enumerate()
.map(|(i, b)| b ^ OBFUSCATION_KEY[i % OBFUSCATION_KEY.len()])
.collect()
}
/// Save credentials to fallback file
fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> {
let path = get_fallback_path().ok_or("Could not determine config directory")?;
// Ensure directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
let json = serde_json::to_string(credentials)
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
let obfuscated = obfuscate(json.as_bytes());
let encoded = BASE64.encode(&obfuscated);
fs::write(&path, encoded)
.map_err(|e| format!("Failed to write credentials file: {}", e))?;
log::info!("Credentials saved to fallback file");
Ok(())
}
/// Load credentials from fallback file
fn load_from_fallback() -> Result<Option<QobuzCredentials>, String> {
let path = match get_fallback_path() {
Some(p) => p,
None => return Ok(None),
};
if !path.exists() {
return Ok(None);
}
let encoded = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read credentials file: {}", e))?;
let obfuscated = BASE64.decode(encoded.trim())
.map_err(|e| format!("Failed to decode credentials: {}", e))?;
let json_bytes = obfuscate(&obfuscated);
let json = String::from_utf8(json_bytes)
.map_err(|e| format!("Failed to decode credentials: {}", e))?;
let credentials: QobuzCredentials = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
log::info!("Credentials loaded from fallback file for: {}", credentials.email);
Ok(Some(credentials))
}
/// Clear fallback credentials file
fn clear_fallback() -> Result<(), String> {
if let Some(path) = get_fallback_path() {
if path.exists() {
fs::remove_file(&path)
.map_err(|e| format!("Failed to remove credentials file: {}", e))?;
log::info!("Fallback credentials file removed");
}
}
Ok(())
}
/// Check if fallback file exists
fn has_fallback_credentials() -> bool {
get_fallback_path().map(|p| p.exists()).unwrap_or(false)
}
/// Save Qobuz credentials - tries keyring first, then fallback
pub fn save_qobuz_credentials(email: &str, password: &str) -> Result<(), String> {
log::info!("Attempting to save credentials for: {}", email);
let entry = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY)
.map_err(|e| {
log::error!("Failed to create keyring entry: {}", e);
format!("Failed to create keyring entry: {}", e)
})?;
let credentials = QobuzCredentials {
email: email.to_string(),
password: password.to_string(),
};
let json = serde_json::to_string(&credentials)
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
// Try keyring first
match Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
Ok(entry) => {
let json = serde_json::to_string(&credentials)
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
entry
.set_password(&json)
.map_err(|e| {
log::error!("Failed to save to keyring: {}", e);
format!("Failed to save to keyring: {}", e)
})?;
log::info!("Qobuz credentials saved to system keyring successfully");
Ok(())
}
/// Load Qobuz credentials from system keyring
pub fn load_qobuz_credentials() -> Result<Option<QobuzCredentials>, String> {
log::info!("Attempting to load credentials from keyring");
let entry = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY)
.map_err(|e| {
log::error!("Failed to create keyring entry for reading: {}", e);
format!("Failed to create keyring entry: {}", e)
})?;
match entry.get_password() {
Ok(json) => {
let credentials: QobuzCredentials = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
log::info!("Successfully loaded credentials from keyring for: {}", credentials.email);
Ok(Some(credentials))
}
Err(keyring::Error::NoEntry) => {
log::info!("No saved credentials found in keyring");
Ok(None)
match entry.set_password(&json) {
Ok(()) => {
log::info!("Qobuz credentials saved to system keyring successfully");
return Ok(());
}
Err(e) => {
log::warn!("Keyring save failed ({}), trying fallback...", e);
}
}
}
Err(e) => {
log::error!("Failed to load from keyring: {}", e);
Err(format!("Failed to load from keyring: {}", e))
log::warn!("Keyring not available ({}), using fallback...", e);
}
}
// Fallback to file
save_to_fallback(&credentials)
}
/// Check if credentials are saved
/// Load Qobuz credentials - tries keyring first, then fallback
pub fn load_qobuz_credentials() -> Result<Option<QobuzCredentials>, String> {
log::info!("Attempting to load credentials");
// Try keyring first
if let Ok(entry) = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
match entry.get_password() {
Ok(json) => {
if let Ok(credentials) = serde_json::from_str::<QobuzCredentials>(&json) {
log::info!("Successfully loaded credentials from keyring for: {}", credentials.email);
return Ok(Some(credentials));
}
}
Err(keyring::Error::NoEntry) => {
log::debug!("No credentials in keyring, checking fallback...");
}
Err(e) => {
log::warn!("Keyring load failed ({}), checking fallback...", e);
}
}
} else {
log::warn!("Keyring not available, checking fallback...");
}
// Try fallback file
load_from_fallback()
}
/// Check if credentials are saved (keyring or fallback)
pub fn has_saved_credentials() -> bool {
let entry = match Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
Ok(e) => e,
Err(_) => return false,
};
// Check keyring
if let Ok(entry) = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
if entry.get_password().is_ok() {
return true;
}
}
entry.get_password().is_ok()
// Check fallback
has_fallback_credentials()
}
/// Clear saved Qobuz credentials
/// Clear saved Qobuz credentials (both keyring and fallback)
pub fn clear_qobuz_credentials() -> Result<(), String> {
let entry = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY)
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
match entry.delete_credential() {
Ok(()) => {
log::info!("Qobuz credentials cleared from keyring");
Ok(())
// Try to clear keyring
if let Ok(entry) = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
match entry.delete_credential() {
Ok(()) => {
log::info!("Qobuz credentials cleared from keyring");
}
Err(keyring::Error::NoEntry) => {
// Already cleared, that's fine
}
Err(e) => {
log::warn!("Failed to clear keyring: {}", e);
}
}
Err(keyring::Error::NoEntry) => {
// Already cleared, that's fine
Ok(())
}
Err(e) => Err(format!("Failed to clear credentials: {}", e)),
}
// Also clear fallback
clear_fallback()?;
Ok(())
}
#[cfg(test)]
+35 -15
View File
@@ -48,38 +48,58 @@ impl SongLinkClient {
}
}
/// Get song.link URL for a track by Qobuz track ID
pub async fn get_by_track_id(&self, track_id: &str) -> Result<SongLinkResponse, ShareError> {
let cache_key = format!("track:{}", track_id);
/// Get song.link URL for a track by ISRC
/// Qobuz isn't supported by Odesli, so we use ISRC to find the track on Spotify
pub async fn get_by_isrc(&self, isrc: &str) -> Result<SongLinkResponse, ShareError> {
let cache_key = format!("isrc:{}", isrc);
// Check cache first
if let Some(cached) = self.get_from_cache(&cache_key) {
log::debug!("Cache hit for track ID: {}", track_id);
log::debug!("Cache hit for ISRC: {}", isrc);
return Ok(cached);
}
// Use Qobuz track URL for Odesli lookup
let qobuz_url = format!("https://open.qobuz.com/track/{}", track_id);
log::info!("Fetching song.link for Qobuz track: {}", qobuz_url);
// Use Spotify search URL with ISRC - Odesli will resolve it
// Format: https://open.spotify.com/search/isrc:{ISRC}
let spotify_search_url = format!("https://open.spotify.com/search/isrc%3A{}", isrc);
log::info!("Fetching song.link via Spotify ISRC search: {}", isrc);
let response = self
.client
.get(ODESLI_API_URL)
.query(&[("url", &qobuz_url), ("userCountry", &"US".to_string())])
.query(&[("url", &spotify_search_url), ("userCountry", &"US".to_string())])
.send()
.await?;
// If Spotify search doesn't work, try Deezer
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(ShareError::OdesliError(format!(
"HTTP {}: {}",
status, text
)));
log::debug!("Spotify search failed, trying Deezer...");
let deezer_url = format!("https://www.deezer.com/search/{}", isrc);
let response = self
.client
.get(ODESLI_API_URL)
.query(&[("url", &deezer_url), ("userCountry", &"US".to_string())])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(ShareError::OdesliError(format!(
"HTTP {}: {}",
status, text
)));
}
let odesli: OdesliResponse = response.json().await?;
let result = self.convert_response(odesli, isrc.to_string(), ContentType::Track)?;
self.store_in_cache(cache_key, result.clone());
return Ok(result);
}
let odesli: OdesliResponse = response.json().await?;
let result = self.convert_response(odesli, track_id.to_string(), ContentType::Track)?;
let result = self.convert_response(odesli, isrc.to_string(), ContentType::Track)?;
// Cache the result
self.store_in_cache(cache_key, result.clone());
+20 -4
View File
@@ -85,11 +85,13 @@
{/if}
<!-- Favorite Indicator -->
{#if isFavorite}
<div class="favorite-indicator">
<div class="favorite-indicator" class:is-favorite={isFavorite}>
{#if isFavorite}
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
</div>
{/if}
{:else}
<Heart size={14} color="var(--text-muted)" />
{/if}
</div>
<div class="track-actions">
<TrackMenu
@@ -228,6 +230,20 @@
align-items: center;
justify-content: center;
width: 24px;
opacity: 0.3;
transition: opacity 150ms ease;
}
.favorite-indicator.is-favorite {
opacity: 1;
}
.track-row:hover .favorite-indicator {
opacity: 0.6;
}
.track-row:hover .favorite-indicator.is-favorite {
opacity: 1;
}
.track-actions {
+50 -7
View File
@@ -472,10 +472,26 @@
}
}
async function shareSonglinkTrack(trackId: number) {
async function shareSonglinkTrack(trackId: number, isrc?: string) {
// Get ISRC - either from parameter or fetch from track details
let resolvedIsrc = isrc;
if (!resolvedIsrc) {
try {
const fullTrack = await invoke<QobuzTrack>('get_track', { trackId });
resolvedIsrc = fullTrack.isrc;
} catch (err) {
console.error('Failed to fetch track ISRC:', err);
}
}
if (!resolvedIsrc) {
showToast('ISRC not available for this track', 'error');
return;
}
try {
showToast('Fetching Song.link...', 'info');
const response = await invoke<SongLinkResponse>('share_track_songlink', { trackId });
const response = await invoke<SongLinkResponse>('share_track_songlink', { isrc: resolvedIsrc });
await copyToClipboard(response.pageUrl, 'Song.link copied');
} catch (err) {
console.error('Failed to get Song.link:', err);
@@ -532,6 +548,9 @@
track.album?.title || '',
artwork || undefined
);
// Check if track is favorite
isFavorite = await checkTrackFavorite(track.id);
} catch (err) {
console.error('Failed to play track:', err);
showToast(`Playback error: ${err}`, 'error');
@@ -676,6 +695,23 @@
}
}
// Check if a track is in favorites
async function checkTrackFavorite(trackId: number): Promise<boolean> {
try {
const response = await invoke<{ tracks?: { items: Array<{ id: number }> } }>('get_favorites', {
favType: 'tracks',
limit: 500
});
if (response.tracks?.items) {
return response.tracks.items.some(item => item.id === trackId);
}
return false;
} catch (err) {
console.error('Failed to check favorite status:', err);
return false;
}
}
async function toggleFavorite() {
if (!currentTrack) return;
@@ -789,6 +825,13 @@
// Update Last.fm
updateLastfmNowPlaying(track.title, track.artist, track.album, track.duration_secs, track.id);
// Check if track is favorite (for Qobuz tracks only)
if (!isLocalTrack) {
isFavorite = await checkTrackFavorite(track.id);
} else {
isFavorite = false;
}
// Refresh queue state
await syncQueueState();
} catch (err) {
@@ -1404,7 +1447,7 @@
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
onArtistClick={handleArtistClick}
@@ -1426,7 +1469,7 @@
onTrackPlayLater={handleAlbumTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
onPlayAll={handlePlayAllAlbum}
@@ -1445,7 +1488,7 @@
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
/>
@@ -1466,7 +1509,7 @@
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
/>
@@ -1480,7 +1523,7 @@
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
/>