feat: Qobuz™ compliance, subscription gating, and settings cleanup
cx
This commit is contained in:
1 parent
ee515573a2
commit
2a8b7c6d55
29 files changed
+918
-306
No files matched your search
@@ -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
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -61,7 +61,7 @@
|
||||
onAboutClick,
|
||||
onLogout,
|
||||
userName = 'User',
|
||||
subscription = 'Qobuz'
|
||||
subscription = 'Qobuz™'
|
||||
}: Props = $props();
|
||||
|
||||
let userPlaylists = $state<Playlist[]>([]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export interface UserInfo {
|
||||
userName: string;
|
||||
subscription: string;
|
||||
subscriptionValidUntil?: string | null;
|
||||
}
|
||||
|
||||
// Auth state
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user