feat: Qobuz™ compliance, subscription gating, and settings cleanup

cx
This commit is contained in:
vicrodh committed 2026-01-22 10:33:45 -06:00
1 parent ee515573a2
commit 2a8b7c6d55
29 files changed
+918 -306

No files matched your search

+16 -8
View File
@@ -12,11 +12,19 @@
# QBZ
QBZ is a free and open source (FOSS) Qobuz client for Linux with native, high-fidelity playback. It is a real desktop application, not a web wrapper, so it can use DAC passthrough, switch sample rates per track, and deliver bit-perfect audio.
QBZ is a free and open source (FOSS) high-fidelity streaming client for Linux with native playback. It is a real desktop application, not a web wrapper, so it can use DAC passthrough, switch sample rates per track, and deliver bit-perfect audio.
## Legal / Branding
- This application uses the Qobuz API but is not certified by Qobuz.
- Qobuz™ is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or certified by Qobuz.
- Offline downloads are a local cache for offline playback only while you have a valid subscription. You do not receive a license to keep or redistribute the content. If your subscription becomes invalid, QBZ will delete all downloaded content after 3 days.
- Credentials may be stored in your system keyring if you have a keyring configured.
- Qobuz Terms of Service: https://www.qobuz.com/us-en/legal/terms
## Why QBZ
Browsers cap audio output around 48 kHz, while Qobuz streams up to 192 kHz. QBZ uses a native playback pipeline with direct device control, exclusive mode, and no forced resampling so your system and DAC receive the original resolution, with caching and system integrations that wrappers cannot provide.
Browsers cap audio output around 48 kHz, while Qobuz™ streams up to 192 kHz. QBZ uses a native playback pipeline with direct device control, exclusive mode, and no forced resampling so your system and DAC receive the original resolution, with caching and system integrations that wrappers cannot provide.
## Installation
@@ -102,9 +110,9 @@ Coming soon.
## Features
### Streaming and Playback
- Qobuz authentication and full catalog search (albums, tracks, artists, playlists).
- Qobuz™ authentication and full catalog search (albums, tracks, artists, playlists).
- Native decoding for FLAC, MP3, AAC, and ALAC with real-time playback state updates.
- Quality selection with automatic fallback across Qobuz tiers.
- Quality selection with automatic fallback across Qobuz™ tiers.
- Audio device enumeration and per-device output selection.
- Exclusive mode and DAC passthrough for bit-perfect playback.
- Preserve original sample rates end-to-end where supported.
@@ -113,14 +121,14 @@ Coming soon.
### Queue and Library
- Queue management with shuffle, repeat, and history navigation.
- In-memory audio cache with LRU eviction and next-track prefetching.
- Favorites and playlists from your Qobuz account.
- Favorites and playlists from your Qobuz™ account.
- Local library backend: directory scanning, metadata extraction, CUE sheet parsing, and SQLite indexing.
- Grid and list views with search, A-Z index, and grouping by artist or album.
- Multi-disc album grouping with disc headers in album views.
- Local artwork detection (folder and embedded) with Discogs fallback.
### Playlist Import
- Import public playlists from Spotify, Apple Music, Tidal, and Deezer into your Qobuz library.
- Import public playlists from Spotify, Apple Music, Tidal, and Deezer into your Qobuz™ library.
- Automatic track matching with fuzzy search.
- Batch import with progress tracking.
@@ -135,7 +143,7 @@ Coming soon.
- Desktop notifications for track changes.
- Last.fm scrobbling and now-playing updates.
- Discogs artwork fetching for local library.
- Shareable Qobuz URLs and universal SongLink links (Odesli).
- Shareable Qobuz™ URLs and universal SongLink links (Odesli).
### Interface
- Now playing, queue panel, and full-screen playback views.
@@ -299,7 +307,7 @@ qbz/
├── src/ # Frontend (SvelteKit)
├── src-tauri/
│ └── src/
│ ├── api/ # Qobuz API client
│ ├── api/ # Qobuz™ API client
│ ├── player/ # Audio playback engine
│ ├── queue/ # Queue management
│ ├── cache/ # Audio cache and prefetch
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "qbz-nix",
"version": "1.1.6",
"description": "Native Qobuz client for Linux with Hi-Fi audio support",
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "qbz-nix"
version = "1.1.6"
description = "Native Qobuz client for Linux with Hi-Fi audio support"
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
authors = ["blitzkriegfc"]
edition = "2021"
+37
View File
@@ -1,5 +1,6 @@
//! Authentication and request signing
use chrono::{TimeZone, Utc};
use md5::{Digest, Md5};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -77,6 +78,41 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
.unwrap_or("Unknown")
.to_string();
fn parse_subscription_valid_until(parameters: &serde_json::Value) -> Option<String> {
// Try common string fields first.
let string_keys = [
"end_date",
"expiration_date",
"valid_until",
"expires_at",
"expiry_date",
];
for key in string_keys {
if let Some(s) = parameters.get(key).and_then(|v| v.as_str()) {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
// Try common timestamp fields (seconds).
let ts_keys = ["end_date_ts", "expires_at_ts", "expiration_ts", "valid_until_ts"];
for key in ts_keys {
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
if ts > 0 {
return Some(Utc.timestamp_opt(ts, 0).single()?.date_naive().to_string());
}
}
}
None
}
let subscription_valid_until = credential
.and_then(|c| c.get("parameters"))
.and_then(parse_subscription_valid_until);
// Check if user has valid subscription
let has_subscription = credential
.and_then(|c| c.get("parameters"))
@@ -93,6 +129,7 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
email,
display_name,
subscription_label,
subscription_valid_until,
})
}
+7 -3
View File
@@ -166,10 +166,14 @@ impl QobuzClient {
*self.session.write().await = None;
}
/// Get current user info (display name and subscription)
pub async fn get_user_info(&self) -> Option<(String, String)> {
/// Get current user info (display name, subscription, and expiry if available)
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
self.session.read().await.as_ref().map(|s| {
(s.display_name.clone(), s.subscription_label.clone())
(
s.display_name.clone(),
s.subscription_label.clone(),
s.subscription_valid_until.clone(),
)
})
}
+2
View File
@@ -50,6 +50,8 @@ pub struct UserSession {
pub email: String,
pub display_name: String,
pub subscription_label: String,
#[serde(default)]
pub subscription_valid_until: Option<String>,
}
/// Stream URL response
+141 -14
View File
@@ -1,16 +1,69 @@
//! Authentication commands
use tauri::State;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::credentials;
use crate::AppState;
use crate::api::error::ApiError;
use crate::config::SubscriptionStateState;
use crate::download_cache::DownloadCacheState;
#[derive(serde::Serialize)]
pub struct LoginResponse {
pub success: bool,
pub user_name: Option<String>,
pub subscription: Option<String>,
pub subscription_valid_until: Option<String>,
pub error: Option<String>,
pub error_code: Option<String>,
}
fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
async fn maybe_purge_downloads_for_invalid_subscription(
now: i64,
subscription_state: &SubscriptionStateState,
cache_state: &DownloadCacheState,
library_state: &crate::library::commands::LibraryState,
) {
let should_purge = {
let store = match subscription_state.lock() {
Ok(s) => s,
Err(e) => {
log::warn!("Subscription state lock error: {}", e);
return;
}
};
match store.should_purge_downloads(now) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to evaluate purge condition: {}", e);
false
}
}
};
if !should_purge {
return;
}
log::warn!("Subscription invalid for >3 days. Purging offline downloads.");
if let Err(e) = crate::download_cache::commands::purge_all_downloads(cache_state, library_state).await {
log::error!("Failed to purge downloads: {}", e);
return;
}
if let Ok(store) = subscription_state.lock() {
if let Err(e) = store.mark_downloads_purged(now) {
log::warn!("Failed to persist purge timestamp: {}", e);
}
}
}
#[tauri::command]
@@ -18,21 +71,54 @@ pub async fn login(
email: String,
password: String,
state: State<'_, AppState>,
subscription_state: State<'_, SubscriptionStateState>,
cache_state: State<'_, DownloadCacheState>,
library_state: State<'_, crate::library::commands::LibraryState>,
) -> Result<LoginResponse, String> {
let client = state.client.lock().await;
let now = now_unix_secs();
match client.login(&email, &password).await {
Ok(session) => Ok(LoginResponse {
success: true,
user_name: Some(session.display_name),
subscription: Some(session.subscription_label),
error: None,
}),
Ok(session) => {
if let Ok(store) = subscription_state.lock() {
let _ = store.mark_valid(now);
}
Ok(LoginResponse {
success: true,
user_name: Some(session.display_name),
subscription: Some(session.subscription_label),
subscription_valid_until: session.subscription_valid_until,
error: None,
error_code: None,
})
}
Err(ApiError::IneligibleUser) => {
if let Ok(store) = subscription_state.lock() {
let _ = store.mark_invalid(now);
}
maybe_purge_downloads_for_invalid_subscription(
now,
subscription_state.inner(),
cache_state.inner(),
library_state.inner(),
)
.await;
Ok(LoginResponse {
success: false,
user_name: None,
subscription: None,
subscription_valid_until: None,
error: Some("No active subscription".to_string()),
error_code: Some("ineligible_user".to_string()),
})
}
Err(e) => Ok(LoginResponse {
success: false,
user_name: None,
subscription: None,
subscription_valid_until: None,
error: Some(e.to_string()),
error_code: None,
}),
}
}
@@ -63,14 +149,16 @@ pub async fn logout(state: State<'_, AppState>) -> Result<(), String> {
pub struct UserInfo {
pub user_name: String,
pub subscription: String,
pub subscription_valid_until: Option<String>,
}
#[tauri::command]
pub async fn get_user_info(state: State<'_, AppState>) -> Result<Option<UserInfo>, String> {
let client = state.client.lock().await;
Ok(client.get_user_info().await.map(|(name, sub)| UserInfo {
Ok(client.get_user_info().await.map(|(name, sub, until)| UserInfo {
user_name: name,
subscription: sub,
subscription_valid_until: until,
}))
}
@@ -97,7 +185,12 @@ pub fn clear_saved_credentials() -> Result<(), String> {
/// Auto-login using saved credentials
/// Returns LoginResponse with success status
#[tauri::command]
pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, String> {
pub async fn auto_login(
state: State<'_, AppState>,
subscription_state: State<'_, SubscriptionStateState>,
cache_state: State<'_, DownloadCacheState>,
library_state: State<'_, crate::library::commands::LibraryState>,
) -> Result<LoginResponse, String> {
// Check for saved credentials
let creds = match credentials::load_qobuz_credentials() {
Ok(Some(c)) => c,
@@ -106,7 +199,9 @@ pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, Str
success: false,
user_name: None,
subscription: None,
subscription_valid_until: None,
error: Some("No saved credentials".to_string()),
error_code: None,
});
}
Err(e) => {
@@ -114,20 +209,50 @@ pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, Str
success: false,
user_name: None,
subscription: None,
subscription_valid_until: None,
error: Some(e),
error_code: None,
});
}
};
// Try to login with saved credentials
let client = state.client.lock().await;
let now = now_unix_secs();
match client.login(&creds.email, &creds.password).await {
Ok(session) => Ok(LoginResponse {
success: true,
user_name: Some(session.display_name),
subscription: Some(session.subscription_label),
error: None,
}),
Ok(session) => {
if let Ok(store) = subscription_state.lock() {
let _ = store.mark_valid(now);
}
Ok(LoginResponse {
success: true,
user_name: Some(session.display_name),
subscription: Some(session.subscription_label),
subscription_valid_until: session.subscription_valid_until,
error: None,
error_code: None,
})
}
Err(ApiError::IneligibleUser) => {
if let Ok(store) = subscription_state.lock() {
let _ = store.mark_invalid(now);
}
maybe_purge_downloads_for_invalid_subscription(
now,
subscription_state.inner(),
cache_state.inner(),
library_state.inner(),
)
.await;
Ok(LoginResponse {
success: false,
user_name: None,
subscription: None,
subscription_valid_until: None,
error: Some("No active subscription".to_string()),
error_code: Some("ineligible_user".to_string()),
})
}
Err(e) => {
// Credentials might be invalid, but don't clear them automatically
// Let the user decide
@@ -136,7 +261,9 @@ pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, Str
success: false,
user_name: None,
subscription: None,
subscription_valid_until: None,
error: Some(e.to_string()),
error_code: None,
})
}
}
+8
View File
@@ -13,6 +13,7 @@ pub mod audio_settings;
pub mod download_settings;
pub mod playback_preferences;
pub mod favorites_preferences;
pub mod subscription_state;
pub use audio_settings::{
AudioSettings,
@@ -47,3 +48,10 @@ pub use favorites_preferences::{
get_favorites_preferences,
save_favorites_preferences,
};
pub use subscription_state::{
SubscriptionState,
SubscriptionStateState,
SubscriptionStateStore,
create_subscription_state,
};
+147
View File
@@ -0,0 +1,147 @@
//! Subscription validity tracking (for offline download compliance)
//!
//! Tracks when a user was first observed without a valid subscription. If the
//! invalid state persists for more than a grace period, offline downloads are
//! purged.
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
const GRACE_PERIOD_SECS: i64 = 3 * 24 * 60 * 60; // 3 days
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionState {
pub invalid_since: Option<i64>,
pub last_invalid_at: Option<i64>,
pub last_valid_at: Option<i64>,
pub last_checked_at: Option<i64>,
pub downloads_purged_at: Option<i64>,
}
impl Default for SubscriptionState {
fn default() -> Self {
Self {
invalid_since: None,
last_invalid_at: None,
last_valid_at: None,
last_checked_at: None,
downloads_purged_at: None,
}
}
}
pub struct SubscriptionStateStore {
conn: Connection,
}
impl SubscriptionStateStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("subscription_state.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS subscription_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
invalid_since INTEGER,
last_invalid_at INTEGER,
last_valid_at INTEGER,
last_checked_at INTEGER,
downloads_purged_at INTEGER
);
INSERT OR IGNORE INTO subscription_state (id) VALUES (1);"
).map_err(|e| format!("Failed to create subscription state table: {}", e))?;
Ok(Self { conn })
}
pub fn get_state(&self) -> Result<SubscriptionState, String> {
self.conn
.query_row(
"SELECT invalid_since, last_invalid_at, last_valid_at, last_checked_at, downloads_purged_at
FROM subscription_state WHERE id = 1",
[],
|row| {
Ok(SubscriptionState {
invalid_since: row.get(0)?,
last_invalid_at: row.get(1)?,
last_valid_at: row.get(2)?,
last_checked_at: row.get(3)?,
downloads_purged_at: row.get(4)?,
})
},
)
.map_err(|e| format!("Failed to read subscription state: {}", e))
}
pub fn mark_valid(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state
SET invalid_since = NULL,
last_valid_at = ?1,
last_checked_at = ?1
WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
Ok(())
}
pub fn mark_invalid(&self, now: i64) -> Result<(), String> {
// Only set invalid_since if it's not already set (first observation).
self.conn
.execute(
"UPDATE subscription_state
SET invalid_since = COALESCE(invalid_since, ?1),
last_invalid_at = ?1,
last_checked_at = ?1
WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
Ok(())
}
pub fn mark_downloads_purged(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state SET downloads_purged_at = ?1 WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update purge timestamp: {}", e))?;
Ok(())
}
pub fn should_purge_downloads(&self, now: i64) -> Result<bool, String> {
let state = self.get_state()?;
let Some(invalid_since) = state.invalid_since else { return Ok(false); };
if now - invalid_since < GRACE_PERIOD_SECS {
return Ok(false);
}
// Purge at most once per invalid period.
if let Some(purged_at) = state.downloads_purged_at {
if purged_at >= invalid_since {
return Ok(false);
}
}
Ok(true)
}
}
pub type SubscriptionStateState = Arc<Mutex<SubscriptionStateStore>>;
pub fn create_subscription_state() -> Result<SubscriptionStateState, String> {
let store = SubscriptionStateStore::new()?;
Ok(Arc::new(Mutex::new(store)))
}
+9 -1
View File
@@ -364,7 +364,14 @@ pub async fn clear_download_cache(
library_state: State<'_, crate::library::commands::LibraryState>,
) -> Result<(), String> {
log::info!("Command: clear_download_cache");
purge_all_downloads(cache_state.inner(), library_state.inner()).await
}
/// Clear entire download cache (internal helper)
pub async fn purge_all_downloads(
cache_state: &DownloadCacheState,
library_state: &crate::library::commands::LibraryState,
) -> Result<(), String> {
let db = cache_state.db.lock().await;
// Get all file paths and clear DB
@@ -391,7 +398,8 @@ pub async fn clear_download_cache(
// Remove all Qobuz downloads from library
let library_db = library_state.db.lock().await;
let removed_count = library_db.remove_all_qobuz_downloads()
let removed_count = library_db
.remove_all_qobuz_downloads()
.map_err(|e| format!("Failed to remove downloads from library: {}", e))?;
log::info!("Removed {} Qobuz downloads from library", removed_count);
+61 -3
View File
@@ -1,7 +1,6 @@
//! QBZ-NIX: Native Qobuz client for Linux
//! QBZ-NIX: Native high-fidelity streaming client for Linux
//!
//! A high-fidelity music streaming client for Qobuz, designed for audiophiles
//! who need bit-perfect playback without browser sample rate limitations.
//! This application uses the Qobuz API but is not certified by Qobuz.
pub mod api;
pub mod api_cache;
@@ -187,6 +186,9 @@ pub fn run() {
// Initialize favorites preferences state
let favorites_prefs_state = config::favorites_preferences::FavoritesPreferencesState::new()
.expect("Failed to initialize favorites preferences");
// Initialize subscription validity tracking (for offline download compliance)
let subscription_state = config::create_subscription_state()
.expect("Failed to initialize subscription state");
// Read saved audio device and settings for player initialization
let (saved_device, audio_settings) = audio_settings_state
@@ -229,6 +231,60 @@ pub fn run() {
.media_controls
.init(app.handle().clone());
// Purge offline downloads if the subscription has been invalid for > 3 days
// (compliance requirement for cached content).
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let subscription_state = app_handle
.state::<config::SubscriptionStateState>()
.inner()
.clone();
let should_purge = {
let store = match subscription_state.lock() {
Ok(s) => s,
Err(e) => {
log::warn!("Subscription state lock error: {}", e);
return;
}
};
match store.should_purge_downloads(now) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to evaluate purge condition: {}", e);
false
}
}
};
if !should_purge {
return;
}
log::warn!("Subscription invalid for >3 days. Purging offline downloads.");
let cache_state = app_handle.state::<download_cache::DownloadCacheState>();
let library_state = app_handle.state::<crate::library::commands::LibraryState>();
if let Err(e) = download_cache::commands::purge_all_downloads(cache_state.inner(), library_state.inner()).await {
log::error!("Failed to purge downloads: {}", e);
return;
}
let store = match subscription_state.lock() {
Ok(s) => s,
Err(e) => {
log::warn!("Subscription state lock error: {}", e);
return;
}
};
if let Err(e) = store.mark_downloads_purged(now) {
log::warn!("Failed to persist purge timestamp: {}", e);
}
});
// Start background task to emit playback events
let app_handle = app.handle().clone();
let player_state = app.state::<AppState>().player.state.clone();
@@ -310,6 +366,7 @@ pub fn run() {
.manage(session_store_state)
.manage(audio_settings_state)
.manage(download_settings_state)
.manage(subscription_state)
.manage(offline_state)
.manage(playback_prefs_state)
.manage(favorites_prefs_state)
@@ -556,6 +613,7 @@ pub fn run() {
download_cache::commands::sync_downloads_to_library,
// Lyrics commands
lyrics::commands::lyrics_get,
lyrics::commands::lyrics_get_cache_stats,
lyrics::commands::lyrics_clear_cache,
// Recommendation store commands
reco_store::commands::reco_log_event,
+8
View File
@@ -131,4 +131,12 @@ impl LyricsCacheDb {
.map_err(|e| format!("Failed to clear lyrics cache: {}", e))?;
Ok(())
}
pub fn count_entries(&self) -> Result<u64, String> {
let count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM lyrics_cache", [], |row| row.get(0))
.map_err(|e| format!("Failed to count lyrics cache entries: {}", e))?;
Ok(count.max(0) as u64)
}
}
+28
View File
@@ -1,6 +1,7 @@
//! Tauri commands for lyrics
use tauri::State;
use serde::Serialize;
use super::{build_cache_key, LyricsPayload, LyricsState};
use super::providers::{fetch_lrclib, fetch_lyrics_ovh};
@@ -82,3 +83,30 @@ pub async fn lyrics_clear_cache(state: State<'_, LyricsState>) -> Result<(), Str
let db = state.db.lock().await;
db.clear()
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LyricsCacheStats {
pub entries: u64,
pub size_bytes: u64,
}
#[tauri::command]
pub async fn lyrics_get_cache_stats(state: State<'_, LyricsState>) -> Result<LyricsCacheStats, String> {
let entries = {
let db = state.db.lock().await;
db.count_entries()?
};
// Approximate on-disk usage as the SQLite DB file size.
let db_path = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("lyrics")
.join("lyrics.db");
let size_bytes = std::fs::metadata(&db_path)
.map(|m| m.len())
.unwrap_or(0);
Ok(LyricsCacheStats { entries, size_bytes })
}
+2 -2
View File
@@ -51,8 +51,8 @@
"icons/icon.ico"
],
"category": "Music",
"shortDescription": "Native Qobuz client for Linux",
"longDescription": "QBZ is a native Qobuz streaming client for Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations.",
"shortDescription": "Native high-fidelity client for Qobuz\u2122 subscribers on Linux",
"longDescription": "QBZ is a native high-fidelity client for Qobuz\u2122 subscribers on Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations. This application uses the Qobuz API but is not certified by Qobuz.",
"linux": {
"deb": {
"depends": [],
+5 -2
View File
@@ -3,6 +3,7 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { getName, getVersion } from '@tauri-apps/api/app';
import { onMount } from 'svelte';
import QobuzLegalNotice from '$lib/components/QobuzLegalNotice.svelte';
interface Props {
isOpen: boolean;
@@ -63,10 +64,12 @@
<div class="modal-content">
<!-- Description -->
<p class="description">
A native Qobuz client for Linux, designed for audiophiles who need
A native music streaming client for Linux, designed for audiophiles who need
bit-perfect Hi-Fi playback without browser sample rate limitations.
</p>
<QobuzLegalNotice showCheckbox={false} />
<!-- Links -->
<div class="links">
<button class="link-btn" onclick={() => handleOpenUrl('https://github.com/vicrodh/qbz')}>
@@ -106,7 +109,7 @@
<h3>Acknowledgments</h3>
<div class="attributions">
<div class="attribution">
<strong>Qobuz</strong> — Music streaming service and API
<strong>Qobuz™</strong> — Music streaming service and API
</div>
<div class="attribution">
<strong>Tauri</strong> — Application framework (MIT/Apache-2.0)
+1 -1
View File
@@ -288,7 +288,7 @@
{#if onShareQobuz}
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
<Link size={14} />
<span>Qobuz link</span>
<span>Qobuz™ link</span>
</button>
{/if}
{#if onShareSonglink}
+1 -1
View File
@@ -134,7 +134,7 @@
<span class="section-label">
{section.label}
{#if section.source === 'qobuz'}
<span class="source-badge qobuz">Qobuz</span>
<span class="source-badge qobuz">Qobuz™</span>
{:else if section.source === 'ml'}
<span class="source-badge ml">ML</span>
{/if}
+1 -1
View File
@@ -83,7 +83,7 @@
This process will:
</p>
<ul>
<li>Fetch complete metadata from Qobuz</li>
<li>Fetch complete metadata from Qobuz™</li>
<li>Add proper tags to your FLAC files</li>
<li>Organize files into Artist/Album folders</li>
<li>Embed album artwork</li>
@@ -3,6 +3,8 @@
import { X, CloudOff } from 'lucide-svelte';
import { showToast } from '$lib/stores/toastStore';
import { t } from '$lib/i18n';
import QobuzLegalNotice from '$lib/components/QobuzLegalNotice.svelte';
import { qobuzTosAccepted } from '$lib/stores/qobuzLegalStore';
import {
subscribe as subscribeOffline,
isOffline as checkIsOffline
@@ -68,7 +70,7 @@
const detectedProvider = $derived(detectProvider(url));
const activeProvider = $derived(lockedProvider ?? detectedProvider);
const isValid = $derived(!!detectedProvider && !isOffline);
const isValid = $derived(!!detectedProvider && !isOffline && $qobuzTosAccepted);
$effect(() => {
if (isOpen) {
@@ -135,7 +137,7 @@
const preview = await invoke<ImportPlaylist>('playlist_import_preview', { url });
pushLog(`Found ${preview.tracks.length} tracks from ${formatProvider(preview.provider)}.`);
pushLog('Matching tracks in Qobuz...');
pushLog('Matching tracks in Qobuz™...');
const result = await invoke<ImportSummary>('playlist_import_execute', {
url,
nameOverride: null,
@@ -148,7 +150,7 @@
pushLog(`Imported ${result.matched_tracks} of ${result.total_tracks} tracks into QBZ.`, 'success');
if (result.qobuz_playlist_id) {
pushLog('Playlist created in Qobuz.', 'success');
pushLog('Playlist created in Qobuz™.', 'success');
} else {
pushLog('No matching tracks found.', 'error');
}
@@ -202,7 +204,7 @@
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="header-title">
<img src="/qobuz-logo.svg" alt="Qobuz" class="qobuz-logo" />
<img src="/qobuz-logo.svg" alt="Qobuz™" class="qobuz-logo" />
<h2>Import Playlist</h2>
</div>
<button class="close-btn" onclick={onClose}>
@@ -211,6 +213,8 @@
</div>
<div class="modal-body">
<QobuzLegalNotice checkboxDisabled={loading} />
{#if isOffline}
<div class="offline-warning" role="alert" aria-live="polite">
<CloudOff size={16} aria-hidden="true" />
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { qobuzTosAccepted } from '$lib/stores/qobuzLegalStore';
interface Props {
showCheckbox?: boolean;
showBrandingLine?: boolean;
showTrademarkNotice?: boolean;
showTermsLink?: boolean;
checkboxDisabled?: boolean;
className?: string;
}
let {
showCheckbox = true,
showBrandingLine = true,
showTrademarkNotice = true,
showTermsLink = false,
checkboxDisabled = false,
className = ''
}: Props = $props();
const TERMS_URL = 'https://www.qobuz.com/us-en/legal/terms';
</script>
<div class={`qobuz-legal ${className}`}>
{#if showBrandingLine}
<p class="branding-line">
This application uses the Qobuz API but is not certified by Qobuz.
</p>
{/if}
{#if showTrademarkNotice}
<p class="trademark-line">
{$t('legal.trademarkNotice')}
</p>
{/if}
{#if showCheckbox}
<label class="tos-line">
<input type="checkbox" bind:checked={$qobuzTosAccepted} disabled={checkboxDisabled} />
<span>
{$t('legal.tosAgreementPrefix')}
<a href={TERMS_URL} target="_blank" rel="noopener">
{$t('legal.tosLinkText')}
</a>
</span>
</label>
{:else if showTermsLink}
<p class="tos-link">
<a href={TERMS_URL} target="_blank" rel="noopener">
{$t('legal.tosLinkText')}
</a>
</p>
{/if}
</div>
<style>
.qobuz-legal {
margin-top: 12px;
}
.branding-line {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
margin: 0 0 10px;
}
.tos-line {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
user-select: none;
}
.tos-line input {
margin-top: 2px;
}
.trademark-line {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
margin: 0 0 10px;
}
.tos-line a,
.tos-link a {
color: var(--accent-primary);
text-decoration: none;
}
.tos-line a:hover,
.tos-link a:hover {
text-decoration: underline;
}
</style>
+1 -1
View File
@@ -61,7 +61,7 @@
onAboutClick,
onLogout,
userName = 'User',
subscription = 'Qobuz'
subscription = 'Qobuz™'
}: Props = $props();
let userPlaylists = $state<Playlist[]>([]);
+1 -1
View File
@@ -332,7 +332,7 @@
{#if onShareQobuz}
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
<Link size={14} />
<span>Qobuz link</span>
<span>Qobuz™ link</span>
</button>
{/if}
{#if onShareSonglink}
+149 -88
View File
@@ -3,10 +3,13 @@
import TitleBar from '../TitleBar.svelte';
import { t } from '$lib/i18n';
import { setManualOffline } from '$lib/stores/offlineStore';
import { qobuzTosAccepted } from '$lib/stores/qobuzLegalStore';
import { get } from 'svelte/store';
interface UserInfo {
userName: string;
subscription: string;
subscriptionValidUntil?: string | null;
}
interface Props {
@@ -21,7 +24,7 @@
let rememberMe = $state(true);
let isLoading = $state(false);
let isInitializing = $state(true);
let initStatus = $state('Connecting to Qobuz...');
let initStatus = $state('Connecting to Qobuz™...');
let error = $state<string | null>(null);
let initError = $state<string | null>(null);
let isTimedOut = $state(false);
@@ -29,7 +32,7 @@
const LOGIN_TIMEOUT_MS = 60000; // 60 seconds
// Initialize the Qobuz client on mount
// Initialize the Qobuz™ client on mount
$effect(() => {
initializeClient();
return () => {
@@ -46,7 +49,7 @@
isInitializing = true;
initError = null;
isTimedOut = false;
initStatus = 'Connecting to Qobuz...';
initStatus = 'Connecting to Qobuz™...';
// Start timeout timer
if (timeoutId) {
@@ -67,11 +70,15 @@
const loggedIn = await invoke<boolean>('is_logged_in');
if (loggedIn) {
clearTimeoutTimer();
const userInfo = await invoke<{ user_name: string; subscription: string } | null>('get_user_info');
const userInfo = await invoke<{ user_name: string; subscription: string; subscription_valid_until?: string | null } | null>('get_user_info');
if (userInfo) {
onLoginSuccess({ userName: userInfo.user_name, subscription: userInfo.subscription });
onLoginSuccess({
userName: userInfo.user_name,
subscription: userInfo.subscription,
subscriptionValidUntil: userInfo.subscription_valid_until ?? null,
});
} else {
onLoginSuccess({ userName: 'Qobuz User', subscription: 'Active' });
onLoginSuccess({ userName: 'User', subscription: 'Active' });
}
return;
}
@@ -80,25 +87,31 @@
initStatus = 'Checking saved credentials...';
const hasSavedCreds = await invoke<boolean>('has_saved_credentials');
if (hasSavedCreds) {
if (hasSavedCreds && get(qobuzTosAccepted)) {
initStatus = 'Logging in...';
const response = await invoke<{
success: boolean;
user_name?: string;
subscription?: string;
subscription_valid_until?: string | null;
error?: string;
error_code?: string;
}>('auto_login');
if (response.success) {
clearTimeoutTimer();
console.log('Auto-login successful');
onLoginSuccess({
userName: response.user_name || 'Qobuz User',
subscription: response.subscription || 'Active'
userName: response.user_name || 'User',
subscription: response.subscription || 'Active',
subscriptionValidUntil: response.subscription_valid_until ?? null,
});
return;
} else {
console.log('Auto-login failed:', response.error);
if (response.error_code === 'ineligible_user') {
error = $t('auth.ineligibleSubscription');
}
// Don't show error, just fall through to manual login
}
}
@@ -131,6 +144,11 @@
async function handleLogin(e: Event) {
e.preventDefault();
if (!get(qobuzTosAccepted)) {
error = $t('legal.tosRequiredToLogin');
return;
}
if (!email || !password) {
error = 'Please enter email and password';
return;
@@ -144,7 +162,9 @@
success: boolean;
user_name?: string;
subscription?: string;
subscription_valid_until?: string | null;
error?: string;
error_code?: string;
}>('login', { email, password });
console.log('Login response:', response);
@@ -162,11 +182,16 @@
}
onLoginSuccess({
userName: response.user_name || 'Qobuz User',
subscription: response.subscription || 'Active'
});
userName: response.user_name || 'User',
subscription: response.subscription || 'Active',
subscriptionValidUntil: response.subscription_valid_until ?? null,
});
} else {
error = response.error || 'Login failed';
if (response.error_code === 'ineligible_user') {
error = $t('auth.ineligibleSubscription');
} else {
error = response.error || 'Login failed';
}
}
} catch (err) {
console.error('Login error:', err);
@@ -194,15 +219,13 @@
<!-- Logo -->
<div class="logo">
<img src="/logo.png" alt="QBZ Logo" class="logo-img" />
<h1>QBZ</h1>
<p class="subtitle">Native Qobuz Client for Linux</p>
</div>
{#if isTimedOut}
<div class="timeout-box">
<p class="timeout-title">Connection is taking too long</p>
<p class="timeout-detail">
Unable to connect to Qobuz after 60 seconds. This could be a network issue or Qobuz may be temporarily unavailable.
Unable to connect to Qobuz™ after 60 seconds. This could be a network issue or Qobuz™ may be temporarily unavailable.
</p>
<div class="timeout-actions">
<button class="retry-btn" onclick={handleRetryLogin}>Try Again</button>
@@ -216,69 +239,86 @@
</div>
{:else if initError}
<div class="error-box">
<p>Failed to connect to Qobuz</p>
<p>Failed to connect to Qobuz™</p>
<p class="error-detail">{initError}</p>
<button class="retry-btn" onclick={initializeClient}>Retry</button>
</div>
{:else}
<form onsubmit={handleLogin}>
<div class="input-group">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
placeholder="your@email.com"
disabled={isLoading}
autocomplete="email"
/>
</div>
<div class="login-body">
<form onsubmit={handleLogin}>
<div class="input-group">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
placeholder="your@email.com"
disabled={isLoading}
autocomplete="email"
/>
</div>
<div class="input-group">
<label for="password">Password</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="Password"
disabled={isLoading}
autocomplete="current-password"
/>
</div>
<div class="input-group">
<label for="password">Password</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="Password"
disabled={isLoading}
autocomplete="current-password"
/>
</div>
<div class="remember-me">
<label>
<input type="checkbox" bind:checked={rememberMe} />
<span>Remember me</span>
</label>
</div>
<div class="remember-me">
<label>
<input type="checkbox" bind:checked={rememberMe} />
<span>Remember me</span>
</label>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="remember-me tos-remember">
<label>
<input type="checkbox" bind:checked={$qobuzTosAccepted} disabled={isLoading} />
<span>
{$t('legal.tosAgreementPrefix')}
<a href="https://www.qobuz.com/us-en/legal/terms" target="_blank" rel="noopener">
{$t('legal.tosLinkText')}
</a>
</span>
</label>
</div>
<button type="submit" class="login-btn" disabled={isLoading}>
{#if isLoading}
<div class="spinner small"></div>
<span>Signing in...</span>
{:else}
<span>Sign in with Qobuz</span>
{#if error}
<div class="error-message">{error}</div>
{/if}
</button>
</form>
<p class="disclaimer">
QBZ requires an active Qobuz subscription.
Your credentials are sent directly to Qobuz and stored securely in your system's keyring.
</p>
<p class="offline-link">
<small>
<button type="button" class="link-button" onclick={handleStartOffline}>
{$t('offline.startWithoutLogin')}
<button type="submit" class="login-btn" disabled={isLoading || !$qobuzTosAccepted}>
{#if isLoading}
<div class="spinner small"></div>
<span>Signing in...</span>
{:else}
<span>Sign in with Qobuz™</span>
{/if}
</button>
</small>
</p>
<p class="offline-link">
<small>
<button type="button" class="link-button" onclick={handleStartOffline}>
{$t('offline.startWithoutLogin')}
</button>
</small>
</p>
</form>
<div class="login-footer">
<p class="footer-copy">
QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™.<br />
This application uses the Qobuz API but is not certified by Qobuz. {$t('legal.trademarkNotice')}
</p>
</div>
</div>
{/if}
</div>
</div>
@@ -300,14 +340,19 @@
background-color: var(--bg-primary);
}
.login-card {
width: 100%;
max-width: 400px;
padding: 48px;
background-color: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.login-card {
width: 100%;
max-width: 720px;
padding: 52px;
background-color: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
min-height: min(700px, 85vh);
max-height: 90vh;
overflow-y: auto;
}
.logo {
text-align: center;
@@ -321,17 +366,33 @@
object-fit: contain;
}
.logo h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-top: 16px;
margin-bottom: 4px;
.login-body {
flex: 1;
display: flex;
flex-direction: column;
}
.subtitle {
font-size: 14px;
color: var(--text-muted);
.login-footer {
margin-top: auto;
padding-top: 16px;
text-align: center;
}
.tos-remember label {
width: 100%;
}
.tos-remember span {
white-space: nowrap;
}
.tos-remember a {
color: var(--accent-primary);
text-decoration: none;
}
.tos-remember a:hover {
text-decoration: underline;
}
.initializing {
@@ -524,16 +585,16 @@
cursor: not-allowed;
}
.disclaimer {
margin-top: 24px;
.footer-copy {
font-size: 12px;
color: var(--text-muted);
text-align: center;
line-height: 1.5;
margin: 0;
}
.offline-link {
margin-top: 16px;
margin-top: 4px;
text-align: center;
}
+106 -144
View File
@@ -2,14 +2,13 @@
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { ArrowLeft, FolderOpen, ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte';
import { ArrowLeft, ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte';
import Toggle from '../Toggle.svelte';
import Dropdown from '../Dropdown.svelte';
import VolumeSlider from '../VolumeSlider.svelte';
import {
getDownloadCacheStats,
clearDownloadCache,
openDownloadCacheFolder,
type DownloadCacheStats
} from '$lib/stores/downloadState';
import { notifyDownloadSettingsChanged } from '$lib/stores/downloadSettingsStore';
@@ -58,6 +57,7 @@
userName?: string;
userEmail?: string;
subscription?: string;
subscriptionValidUntil?: string | null;
}
interface CacheStats {
@@ -80,7 +80,14 @@
is_active: boolean;
}
let { onBack, onLogout, userName = 'User', userEmail = '', subscription = 'Qobuz' }: Props = $props();
let {
onBack,
onLogout,
userName = 'User',
userEmail = '',
subscription = 'Qobuz™',
subscriptionValidUntil = null
}: Props = $props();
// Cache state (memory cache)
let cacheStats = $state<CacheStats | null>(null);
@@ -93,6 +100,7 @@
// Lyrics cache state
let isClearingLyrics = $state(false);
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
// Migration state
let showMigrationModal = $state(false);
@@ -117,7 +125,6 @@
let librarySection: HTMLElement;
let integrationsSection: HTMLElement;
let storageSection: HTMLElement;
let lyricsSection: HTMLElement;
let activeSection = $state('audio');
// Navigation section definitions (static, refs resolved at click/scroll time)
@@ -130,7 +137,6 @@
{ id: 'library', label: 'Library' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'storage', label: 'Storage' },
{ id: 'lyrics', label: 'Lyrics' },
];
// Get section element by id (resolved at call time, not definition time)
@@ -144,7 +150,6 @@
case 'library': return librarySection;
case 'integrations': return integrationsSection;
case 'storage': return storageSection;
case 'lyrics': return lyricsSection;
default: return undefined;
}
}
@@ -366,7 +371,6 @@
// Library settings
let fetchQobuzArtistImages = $state(true);
let showQobuzDownloadsInLibrary = $state(false);
let downloadRoot = $state('');
// Last.fm integration state
let lastfmConnected = $state(false);
@@ -431,6 +435,9 @@
// Load download cache stats
loadDownloadStats();
// Load lyrics cache stats
loadLyricsCacheStats();
// Load audio devices first (includes PipeWire sinks), then settings
// Also load backends and ALSA plugins
Promise.all([
@@ -1146,6 +1153,16 @@
}
}
async function loadLyricsCacheStats() {
try {
const stats = await invoke<{ entries: number; sizeBytes: number }>('lyrics_get_cache_stats');
lyricsCacheStats = stats;
} catch (err) {
console.error('Failed to load lyrics cache stats:', err);
lyricsCacheStats = null;
}
}
async function loadDownloadStats() {
try {
downloadStats = await getDownloadCacheStats();
@@ -1158,7 +1175,6 @@
try {
const settings = await invoke<{download_root: string, show_in_library: boolean}>('get_download_settings');
showQobuzDownloadsInLibrary = settings.show_in_library;
downloadRoot = settings.download_root;
} catch (err) {
console.error('Failed to load download settings:', err);
}
@@ -1220,78 +1236,6 @@
loadDownloadStats();
}
async function handleChangeDownloadFolder() {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const result = await open({
title: 'Select Downloads Folder',
directory: true,
defaultPath: downloadRoot || undefined
});
if (result) {
// Validate the path
const valid = await invoke<boolean>('validate_download_path', { path: result });
if (!valid) {
alert('Invalid path or insufficient permissions. Please select a different folder.');
return;
}
// Check if this is a network folder and warn user
interface NetworkPathInfo {
isNetwork: boolean;
mountInfo: { fsType: string; kind: string } | null;
path: string;
}
const networkInfo = await invoke<NetworkPathInfo>('check_network_path', { path: result });
if (networkInfo.isNetwork) {
const proceed = confirm(
$t('settings.networkDownloadWarning') + '\n\n' +
$t('settings.networkDownloadWarningDetail')
);
if (!proceed) {
return;
}
}
// Ask if user wants to move existing downloads
const hasDownloads = downloadStats && downloadStats.readyTracks > 0;
let moveFiles = false;
if (hasDownloads) {
moveFiles = confirm(
`You have ${downloadStats!.readyTracks} downloaded tracks.\n\n` +
'Would you like to move them to the new location?\n\n' +
'Click OK to move files, or Cancel to keep them in the old location.'
);
}
// Update download root
const moveResult = await invoke<{moved: number, failed: string[]}>('set_download_root', {
path: result,
moveExisting: moveFiles
});
downloadRoot = result;
if (moveFiles && moveResult.moved > 0) {
alert(`Successfully moved ${moveResult.moved} tracks to the new location.`);
}
if (moveResult.failed.length > 0) {
console.error('Failed to move files:', moveResult.failed);
}
// Reload download stats
await loadDownloadStats();
}
} catch (err) {
console.error('Failed to change download folder:', err);
alert(`Failed to change download folder: ${err}`);
}
}
async function handleRepairDownloads() {
if (isRepairingDownloads) return;
isRepairingDownloads = true;
@@ -1331,14 +1275,6 @@
}
}
async function handleOpenDownloadFolder() {
try {
await openDownloadCacheFolder();
} catch (err) {
console.error('Failed to open download folder:', err);
}
}
async function handleClearCache() {
if (isClearing) return;
isClearing = true;
@@ -1358,6 +1294,7 @@
try {
await clearLyricsCache();
console.log('Lyrics cache cleared');
await loadLyricsCacheStats();
} catch (err) {
console.error('Failed to clear lyrics cache:', err);
} finally {
@@ -1433,6 +1370,11 @@
<div class="email">{userEmail}</div>
{/if}
<div class="subscription">{subscription}</div>
{#if subscriptionValidUntil}
<div class="subscription-until">
{$t('settings.account.validUntil', { values: { date: subscriptionValidUntil } })}
</div>
{/if}
</div>
<button class="logout-btn" onclick={onLogout}>{$t('settings.account.logout')}</button>
</div>
@@ -1726,19 +1668,10 @@
<!-- Downloads Section -->
<section class="section" bind:this={downloadsSection}>
<h3 class="section-title">Downloads</h3>
<h3 class="section-title">{$t('settings.downloads.title')}</h3>
<p class="section-note">{$t('settings.downloads.offlineCacheDisclaimer')}</p>
<div class="setting-row">
<div class="setting-with-description">
<span class="setting-label">Download Folder</span>
<span class="setting-description">{downloadRoot || 'Default location'}</span>
</div>
<button class="secondary-btn" onclick={handleChangeDownloadFolder}>
<FolderOpen size={14} />
<span>Change</span>
</button>
</div>
<div class="setting-row">
<span class="setting-label">Downloaded Tracks</span>
<span class="setting-label">{$t('settings.downloads.downloadedTracks')}</span>
<span class="setting-value">
{#if downloadStats}
{downloadStats.readyTracks} tracks ({formatBytes(downloadStats.totalSizeBytes)})
@@ -1750,7 +1683,7 @@
<div class="setting-row">
<div class="setting-with-description">
<span class="setting-label">Show in Local Library</span>
<span class="setting-description">Display downloaded Qobuz tracks in your Local Library</span>
<span class="setting-description">Display downloaded Qobuz™ tracks in your Local Library</span>
</div>
<Toggle enabled={showQobuzDownloadsInLibrary} onchange={handleShowDownloadsChange} />
</div>
@@ -1767,7 +1700,7 @@
{isRepairingDownloads ? 'Repairing...' : 'Repair'}
</button>
</div>
<div class="setting-row">
<div class="setting-row last">
<span class="setting-label">Clear Downloads</span>
<button
class="clear-btn"
@@ -1777,17 +1710,6 @@
{isClearingDownloads ? 'Clearing...' : 'Clear All'}
</button>
</div>
<div class="setting-row last">
<span class="setting-label">Open Folder</span>
<button
class="folder-btn"
onclick={handleOpenDownloadFolder}
title="Open download folder"
>
<FolderOpen size={16} />
<span>Open</span>
</button>
</div>
</section>
<!-- Library Section -->
@@ -1900,28 +1822,24 @@
<!-- Storage Section (Memory Cache) -->
<section class="section" bind:this={storageSection}>
<h3 class="section-title">{$t('settings.storage.title')}</h3>
<p class="section-note">{$t('settings.storage.queueCacheNote')}</p>
<div class="setting-row">
<span class="setting-label">{$t('settings.storage.cacheSize')}</span>
<span class="setting-value">
{#if cacheStats}
{formatBytes(cacheStats.current_size_bytes)} / {formatBytes(cacheStats.max_size_bytes)}
{:else}
{$t('actions.loading')}
{/if}
</span>
</div>
<div class="setting-row">
<span class="setting-label">{$t('settings.storage.cachedTracks')}</span>
<span class="setting-value">
{#if cacheStats}
{cacheStats.cached_tracks} {$t('album.tracks')}
{:else}
-
{/if}
</span>
</div>
<div class="setting-row last">
<span class="setting-label">{$t('settings.storage.clearCache')}</span>
<div class="setting-info">
<span class="setting-label">{$t('settings.storage.clearCache')}</span>
<small class="setting-note">
{#if cacheStats}
{$t('settings.storage.queueCacheStats', {
values: {
tracks: cacheStats.cached_tracks,
used: formatBytes(cacheStats.current_size_bytes),
max: formatBytes(cacheStats.max_size_bytes)
}
})}
{:else}
{$t('actions.loading')}
{/if}
</small>
</div>
<button
class="clear-btn"
onclick={handleClearCache}
@@ -1930,17 +1848,22 @@
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
</button>
</div>
</section>
<!-- Lyrics Section -->
<section class="section" bind:this={lyricsSection}>
<h3 class="section-title">{$t('settings.lyrics.title')}</h3>
<div class="setting-row">
<span class="setting-label">Provider</span>
<span class="setting-value">LRCLIB / lyrics.ovh</span>
</div>
<div class="setting-row last">
<span class="setting-label">{$t('settings.lyrics.clearLyrics')}</span>
<div class="setting-info">
<span class="setting-label">{$t('settings.lyrics.clearLyrics')}</span>
<small class="setting-note">
{#if lyricsCacheStats}
{$t('settings.lyrics.cacheStats', {
values: {
entries: lyricsCacheStats.entries,
size: formatBytes(lyricsCacheStats.sizeBytes)
}
})}
{:else}
-
{/if}
</small>
</div>
<button
class="clear-btn"
onclick={handleClearLyricsCache}
@@ -2146,6 +2069,13 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
margin-bottom: 16px;
}
.section-note {
margin: -6px 0 16px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.account-card {
display: flex;
align-items: center;
@@ -2186,6 +2116,12 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
color: var(--accent-primary);
}
.subscription-until {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.logout-btn {
padding: 8px 24px;
border-radius: 8px;
@@ -2208,6 +2144,7 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
justify-content: space-between;
height: 48px;
border-bottom: 1px solid var(--bg-tertiary);
gap: 16px;
}
.setting-row.last {
@@ -2223,6 +2160,8 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
display: flex;
flex-direction: column;
gap: 2px;
flex: 0 1 60%;
min-width: 0;
}
.setting-description {
@@ -2235,6 +2174,8 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
flex-direction: column;
gap: 2px;
flex: 1;
max-width: 60%;
min-width: 0;
}
.setting-desc {
@@ -2255,6 +2196,13 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
padding: 12px 0;
}
.setting-row:has(.setting-description) {
height: auto;
min-height: 48px;
padding: 12px 0;
align-items: flex-start;
}
.setting-row:has(.setting-desc) {
height: auto;
min-height: 48px;
@@ -2391,6 +2339,20 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
background-color: var(--bg-hover);
}
/* Harmonize button widths across settings rows */
.connect-btn,
.clear-btn,
.folder-btn,
.logout-btn {
min-width: 140px;
padding-top: 7px;
padding-bottom: 7px;
}
.folder-btn {
justify-content: center;
}
/* Last.fm styles */
.lastfm-connected {
display: flex;
+28 -15
View File
@@ -1,7 +1,7 @@
{
"app": {
"name": "QBZ",
"tagline": "Hi-Fi Qobuz Client for Linux"
"tagline": "Hi-Fi streaming client for Linux"
},
"nav": {
"home": "Home",
@@ -54,7 +54,7 @@
"searching": "Searching...",
"recentSearches": "Recent Searches",
"clearHistory": "Clear History",
"startTyping": "Start typing to search Qobuz",
"startTyping": "Start typing to search Qobuz™",
"noAlbumsFor": "No albums found for \"{query}\"",
"noTracksFor": "No tracks found for \"{query}\"",
"noArtistsFor": "No artists found for \"{query}\""
@@ -170,7 +170,7 @@
"renamePlaylist": "Rename Playlist",
"allPlaylists": "All Playlists",
"myPlaylists": "My Playlists",
"qobuzPlaylists": "Qobuz Playlists",
"qobuzPlaylists": "Qobuz™ Playlists",
"emptyPlaylist": "This playlist is empty",
"addTracks": "Add Tracks",
"removeTracks": "Remove",
@@ -307,6 +307,7 @@
"title": "Account",
"loggedInAs": "Logged in as",
"subscription": "Subscription",
"validUntil": "Valid until {date}",
"logout": "Logout",
"login": "Login"
},
@@ -349,13 +350,13 @@
"accentColor": "Accent Color",
"language": "Language",
"uiScale": "UI Scale",
"inAppToasts": "In-app Toasts",
"inAppToasts": "In-app toasts notifications",
"systemNotifications": "System Notifications"
},
"library": {
"title": "Library",
"fetchArtistImages": "Fetch Qobuz Artist Images",
"fetchArtistImagesDesc": "Show artist photos from Qobuz in local library"
"fetchArtistImages": "Fetch Qobuz™ Artist Images",
"fetchArtistImagesDesc": "Show artist photos from Qobuz™ in local library"
},
"integrations": {
"title": "Integrations",
@@ -373,13 +374,16 @@
},
"storage": {
"title": "Storage",
"cachedTracks": "Cached Tracks",
"cacheSize": "Cache Size",
"clearCache": "Clear Cache",
"queueCacheNote": "Queue list storage. Clear if you have playback issues. (This is not the Downloads cache.)",
"queueCacheStats": "{tracks} tracks using {used} / {max}",
"cachedTracks": "Cached Queue Tracks",
"cacheSize": "Queue Cache Size",
"clearCache": "Clear queue cache",
"clearing": "Clearing..."
},
"downloads": {
"title": "Downloads",
"offlineCacheDisclaimer": "Offline downloads are a local cache for offline playback only while you have a valid subscription. You do not receive a license to keep or redistribute the content. If your subscription becomes invalid, QBZ will delete all downloaded content after 3 days.",
"downloadedTracks": "Downloaded Tracks",
"clearDownloads": "Clear All",
"openFolder": "Open Folder"
@@ -389,7 +393,8 @@
"lyrics": {
"title": "Lyrics",
"lyricsCache": "Lyrics Cache",
"clearLyrics": "Clear Lyrics"
"clearLyrics": "Clear Lyrics Cache",
"cacheStats": "{entries} entries using {size}"
},
"about": {
"title": "About",
@@ -407,7 +412,15 @@
"loggingIn": "Signing in...",
"loginError": "Login failed. Please check your credentials.",
"welcomeBack": "Welcome back",
"rememberMe": "Remember me"
"rememberMe": "Remember me",
"ineligibleSubscription": "No active subscription. QBZ will run in offline mode only."
},
"legal": {
"brandingLineSuffix": "",
"tosAgreementPrefix": "I have read the",
"tosLinkText": "Qobuz™ Terms of Service",
"tosRequiredToLogin": "Please confirm that you have read the Qobuz™ Terms of Service to continue.",
"trademarkNotice": "Qobuz™ is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or certified by Qobuz."
},
"notifications": {
"trackAdded": "Track added to queue",
@@ -478,11 +491,11 @@
},
"offline": {
"title": "Offline Mode",
"message": "Connect to the internet to access Qobuz content.",
"message": "Connect to the internet to access Qobuz™ content.",
"noNetwork": "No Network Connection",
"noNetworkMessage": "Check your internet connection to access Qobuz streaming.",
"noNetworkMessage": "Check your internet connection to access Qobuz™ streaming.",
"notLoggedIn": "Not Logged In",
"notLoggedInMessage": "Log in to your Qobuz account to access streaming content.",
"notLoggedInMessage": "Log in to your Qobuz™ account to access streaming content.",
"manualMode": "Offline Mode Enabled",
"manualModeMessage": "You have manually enabled offline mode. Your local library is still available.",
"goToLibrary": "Go to Local Library",
@@ -499,7 +512,7 @@
"notAvailable": "Not Available Offline",
"trackNotAvailable": "This track is not available offline",
"featureDisabled": "This feature requires internet connection",
"startWithoutLogin": "Start in offline mode without logging in",
"startWithoutLogin": "Start offline (no access to Qobuz™ services)",
"noNetworkToast": "No internet connection available",
"checkingNetwork": "Checking network connection...",
"available": "Fully Available Offline",
+27 -14
View File
@@ -1,7 +1,7 @@
{
"app": {
"name": "QBZ",
"tagline": "Cliente Hi-Fi de Qobuz para Linux"
"tagline": "Cliente Hi-Fi de streaming para Linux"
},
"nav": {
"home": "Inicio",
@@ -54,7 +54,7 @@
"searching": "Buscando...",
"recentSearches": "Búsquedas Recientes",
"clearHistory": "Limpiar Historial",
"startTyping": "Escribe para buscar en Qobuz",
"startTyping": "Escribe para buscar en Qobuz™",
"noAlbumsFor": "No se encontraron álbumes para \"{query}\"",
"noTracksFor": "No se encontraron pistas para \"{query}\"",
"noArtistsFor": "No se encontraron artistas para \"{query}\""
@@ -170,7 +170,7 @@
"renamePlaylist": "Renombrar Lista",
"allPlaylists": "Todas las Listas",
"myPlaylists": "Mis Listas",
"qobuzPlaylists": "Listas de Qobuz",
"qobuzPlaylists": "Listas de Qobuz™",
"emptyPlaylist": "Esta lista está vacía",
"addTracks": "Agregar Pistas",
"removeTracks": "Eliminar",
@@ -307,6 +307,7 @@
"title": "Cuenta",
"loggedInAs": "Conectado como",
"subscription": "Suscripción",
"validUntil": "Válida hasta {date}",
"logout": "Cerrar Sesión",
"login": "Iniciar Sesión"
},
@@ -354,8 +355,8 @@
},
"library": {
"title": "Biblioteca",
"fetchArtistImages": "Obtener Imágenes de Artistas de Qobuz",
"fetchArtistImagesDesc": "Mostrar fotos de artistas de Qobuz en biblioteca local"
"fetchArtistImages": "Obtener imágenes de artistas de Qobuz™",
"fetchArtistImagesDesc": "Mostrar fotos de artistas de Qobuz™ en la biblioteca local"
},
"integrations": {
"title": "Integraciones",
@@ -373,13 +374,16 @@
},
"storage": {
"title": "Almacenamiento",
"cachedTracks": "Pistas en Caché",
"cacheSize": "Tamaño de Caché",
"clearCache": "Limpiar Caché",
"queueCacheNote": "Almacenamiento de la cola. Límpialo si tienes problemas de reproducción. (No es el caché de Descargas.)",
"queueCacheStats": "{tracks} pistas usando {used} / {max}",
"cachedTracks": "Pistas de la cola en caché",
"cacheSize": "Tamaño de caché de la cola",
"clearCache": "Limpiar caché de la cola",
"clearing": "Limpiando..."
},
"downloads": {
"title": "Descargas",
"offlineCacheDisclaimer": "Las descargas offline son un caché local solo para reproducción sin conexión mientras tengas una suscripción válida. No recibes una licencia para conservar o redistribuir el contenido. Si tu suscripción deja de ser válida, QBZ eliminará todo el contenido descargado después de 3 días.",
"downloadedTracks": "Pistas Descargadas",
"clearDownloads": "Limpiar Todo",
"openFolder": "Abrir Carpeta"
@@ -389,7 +393,8 @@
"lyrics": {
"title": "Letras",
"lyricsCache": "Caché de Letras",
"clearLyrics": "Limpiar Letras"
"clearLyrics": "Limpiar caché de letras",
"cacheStats": "{entries} entradas usando {size}"
},
"about": {
"title": "Acerca de",
@@ -407,7 +412,15 @@
"loggingIn": "Iniciando sesión...",
"loginError": "Error al iniciar sesión. Verifica tus credenciales.",
"welcomeBack": "Bienvenido de nuevo",
"rememberMe": "Recordarme"
"rememberMe": "Recordarme",
"ineligibleSubscription": "No hay una suscripción activa. QBZ solo funcionará en modo offline."
},
"legal": {
"brandingLineSuffix": "Esta aplicación usa la API de Qobuz™ pero no está certificada por Qobuz™.",
"tosAgreementPrefix": "He leído los",
"tosLinkText": "Términos de servicio de Qobuz™",
"tosRequiredToLogin": "Confirma que has leído los Términos de servicio de Qobuz™ para continuar.",
"trademarkNotice": "Qobuz™ es una marca comercial de Qobuz. QBZ no está afiliada, respaldada ni certificada por Qobuz."
},
"notifications": {
"trackAdded": "Pista agregada a la cola",
@@ -478,11 +491,11 @@
},
"offline": {
"title": "Modo Offline",
"message": "Conecta a internet para acceder al contenido de Qobuz.",
"message": "Conecta a internet para acceder al contenido de Qobuz™.",
"noNetwork": "Sin Conexion a Internet",
"noNetworkMessage": "Verifica tu conexion para acceder al streaming de Qobuz.",
"noNetworkMessage": "Verifica tu conexión para acceder al streaming de Qobuz™.",
"notLoggedIn": "No Has Iniciado Sesion",
"notLoggedInMessage": "Inicia sesion en tu cuenta de Qobuz para acceder al contenido.",
"notLoggedInMessage": "Inicia sesión en tu cuenta de Qobuz™ para acceder al contenido.",
"manualMode": "Modo Offline Activado",
"manualModeMessage": "Has activado el modo offline manualmente. Tu biblioteca local sigue disponible.",
"goToLibrary": "Ir a Biblioteca Local",
@@ -499,7 +512,7 @@
"notAvailable": "No Disponible Offline",
"trackNotAvailable": "Esta pista no esta disponible offline",
"featureDisabled": "Esta funcion requiere conexion a internet",
"startWithoutLogin": "Iniciar en modo offline sin autenticacion",
"startWithoutLogin": "Iniciar en modo offline (sin acceso a los servicios de Qobuz™)",
"noNetworkToast": "No hay conexion a internet disponible",
"checkingNetwork": "Verificando conexion a internet...",
"available": "Totalmente Disponible Offline",
+1
View File
@@ -7,6 +7,7 @@
export interface UserInfo {
userName: string;
subscription: string;
subscriptionValidUntil?: string | null;
}
// Auth state
+18
View File
@@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
const STORAGE_KEY = 'qbz-qobuz-tos-accepted';
function readInitial(): boolean {
if (!browser) return false;
return localStorage.getItem(STORAGE_KEY) === 'true';
}
export const qobuzTosAccepted = writable<boolean>(readInitial());
if (browser) {
qobuzTosAccepted.subscribe((value) => {
localStorage.setItem(STORAGE_KEY, String(value));
});
}
+2 -1
View File
@@ -2090,7 +2090,7 @@
onAboutClick={() => isAboutModalOpen = true}
onLogout={handleLogout}
userName={userInfo?.userName || 'User'}
subscription={userInfo?.subscription || 'Qobuz'}
subscription={userInfo?.subscription || 'Qobuz™'}
/>
<!-- Content Area (main + lyrics sidebar) -->
@@ -2177,6 +2177,7 @@
onLogout={handleLogout}
userName={userInfo?.userName}
subscription={userInfo?.subscription}
subscriptionValidUntil={userInfo?.subscriptionValidUntil}
/>
{:else if activeView === 'album' && selectedAlbum}
<AlbumDetailView