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
|
||||||
|
|
||||||
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
|
## 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
|
## Installation
|
||||||
|
|
||||||
@@ -102,9 +110,9 @@ Coming soon.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Streaming and Playback
|
### 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.
|
- 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.
|
- Audio device enumeration and per-device output selection.
|
||||||
- Exclusive mode and DAC passthrough for bit-perfect playback.
|
- Exclusive mode and DAC passthrough for bit-perfect playback.
|
||||||
- Preserve original sample rates end-to-end where supported.
|
- Preserve original sample rates end-to-end where supported.
|
||||||
@@ -113,14 +121,14 @@ Coming soon.
|
|||||||
### Queue and Library
|
### Queue and Library
|
||||||
- Queue management with shuffle, repeat, and history navigation.
|
- Queue management with shuffle, repeat, and history navigation.
|
||||||
- In-memory audio cache with LRU eviction and next-track prefetching.
|
- 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.
|
- 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.
|
- 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.
|
- Multi-disc album grouping with disc headers in album views.
|
||||||
- Local artwork detection (folder and embedded) with Discogs fallback.
|
- Local artwork detection (folder and embedded) with Discogs fallback.
|
||||||
|
|
||||||
### Playlist Import
|
### 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.
|
- Automatic track matching with fuzzy search.
|
||||||
- Batch import with progress tracking.
|
- Batch import with progress tracking.
|
||||||
|
|
||||||
@@ -135,7 +143,7 @@ Coming soon.
|
|||||||
- Desktop notifications for track changes.
|
- Desktop notifications for track changes.
|
||||||
- Last.fm scrobbling and now-playing updates.
|
- Last.fm scrobbling and now-playing updates.
|
||||||
- Discogs artwork fetching for local library.
|
- Discogs artwork fetching for local library.
|
||||||
- Shareable Qobuz URLs and universal SongLink links (Odesli).
|
- Shareable Qobuz™ URLs and universal SongLink links (Odesli).
|
||||||
|
|
||||||
### Interface
|
### Interface
|
||||||
- Now playing, queue panel, and full-screen playback views.
|
- Now playing, queue panel, and full-screen playback views.
|
||||||
@@ -299,7 +307,7 @@ qbz/
|
|||||||
├── src/ # Frontend (SvelteKit)
|
├── src/ # Frontend (SvelteKit)
|
||||||
├── src-tauri/
|
├── src-tauri/
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── api/ # Qobuz API client
|
│ ├── api/ # Qobuz™ API client
|
||||||
│ ├── player/ # Audio playback engine
|
│ ├── player/ # Audio playback engine
|
||||||
│ ├── queue/ # Queue management
|
│ ├── queue/ # Queue management
|
||||||
│ ├── cache/ # Audio cache and prefetch
|
│ ├── cache/ # Audio cache and prefetch
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "qbz-nix",
|
"name": "qbz-nix",
|
||||||
"version": "1.1.6",
|
"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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "qbz-nix"
|
name = "qbz-nix"
|
||||||
version = "1.1.6"
|
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"]
|
authors = ["blitzkriegfc"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Authentication and request signing
|
//! Authentication and request signing
|
||||||
|
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -77,6 +78,41 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
|
|||||||
.unwrap_or("Unknown")
|
.unwrap_or("Unknown")
|
||||||
.to_string();
|
.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
|
// Check if user has valid subscription
|
||||||
let has_subscription = credential
|
let has_subscription = credential
|
||||||
.and_then(|c| c.get("parameters"))
|
.and_then(|c| c.get("parameters"))
|
||||||
@@ -93,6 +129,7 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
|
|||||||
email,
|
email,
|
||||||
display_name,
|
display_name,
|
||||||
subscription_label,
|
subscription_label,
|
||||||
|
subscription_valid_until,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,10 +166,14 @@ impl QobuzClient {
|
|||||||
*self.session.write().await = None;
|
*self.session.write().await = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current user info (display name and subscription)
|
/// Get current user info (display name, subscription, and expiry if available)
|
||||||
pub async fn get_user_info(&self) -> Option<(String, String)> {
|
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
|
||||||
self.session.read().await.as_ref().map(|s| {
|
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 email: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub subscription_label: String,
|
pub subscription_label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subscription_valid_until: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream URL response
|
/// Stream URL response
|
||||||
|
|||||||
+141
-14
@@ -1,16 +1,69 @@
|
|||||||
//! Authentication commands
|
//! Authentication commands
|
||||||
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::credentials;
|
use crate::credentials;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::api::error::ApiError;
|
||||||
|
use crate::config::SubscriptionStateState;
|
||||||
|
use crate::download_cache::DownloadCacheState;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
pub user_name: Option<String>,
|
pub user_name: Option<String>,
|
||||||
pub subscription: Option<String>,
|
pub subscription: Option<String>,
|
||||||
|
pub subscription_valid_until: Option<String>,
|
||||||
pub error: 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]
|
#[tauri::command]
|
||||||
@@ -18,21 +71,54 @@ pub async fn login(
|
|||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
subscription_state: State<'_, SubscriptionStateState>,
|
||||||
|
cache_state: State<'_, DownloadCacheState>,
|
||||||
|
library_state: State<'_, crate::library::commands::LibraryState>,
|
||||||
) -> Result<LoginResponse, String> {
|
) -> Result<LoginResponse, String> {
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
|
let now = now_unix_secs();
|
||||||
|
|
||||||
match client.login(&email, &password).await {
|
match client.login(&email, &password).await {
|
||||||
Ok(session) => Ok(LoginResponse {
|
Ok(session) => {
|
||||||
success: true,
|
if let Ok(store) = subscription_state.lock() {
|
||||||
user_name: Some(session.display_name),
|
let _ = store.mark_valid(now);
|
||||||
subscription: Some(session.subscription_label),
|
}
|
||||||
error: None,
|
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 {
|
Err(e) => Ok(LoginResponse {
|
||||||
success: false,
|
success: false,
|
||||||
user_name: None,
|
user_name: None,
|
||||||
subscription: None,
|
subscription: None,
|
||||||
|
subscription_valid_until: None,
|
||||||
error: Some(e.to_string()),
|
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 struct UserInfo {
|
||||||
pub user_name: String,
|
pub user_name: String,
|
||||||
pub subscription: String,
|
pub subscription: String,
|
||||||
|
pub subscription_valid_until: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_user_info(state: State<'_, AppState>) -> Result<Option<UserInfo>, String> {
|
pub async fn get_user_info(state: State<'_, AppState>) -> Result<Option<UserInfo>, String> {
|
||||||
let client = state.client.lock().await;
|
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,
|
user_name: name,
|
||||||
subscription: sub,
|
subscription: sub,
|
||||||
|
subscription_valid_until: until,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +185,12 @@ pub fn clear_saved_credentials() -> Result<(), String> {
|
|||||||
/// Auto-login using saved credentials
|
/// Auto-login using saved credentials
|
||||||
/// Returns LoginResponse with success status
|
/// Returns LoginResponse with success status
|
||||||
#[tauri::command]
|
#[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
|
// Check for saved credentials
|
||||||
let creds = match credentials::load_qobuz_credentials() {
|
let creds = match credentials::load_qobuz_credentials() {
|
||||||
Ok(Some(c)) => c,
|
Ok(Some(c)) => c,
|
||||||
@@ -106,7 +199,9 @@ pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, Str
|
|||||||
success: false,
|
success: false,
|
||||||
user_name: None,
|
user_name: None,
|
||||||
subscription: None,
|
subscription: None,
|
||||||
|
subscription_valid_until: None,
|
||||||
error: Some("No saved credentials".to_string()),
|
error: Some("No saved credentials".to_string()),
|
||||||
|
error_code: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -114,20 +209,50 @@ pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, Str
|
|||||||
success: false,
|
success: false,
|
||||||
user_name: None,
|
user_name: None,
|
||||||
subscription: None,
|
subscription: None,
|
||||||
|
subscription_valid_until: None,
|
||||||
error: Some(e),
|
error: Some(e),
|
||||||
|
error_code: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to login with saved credentials
|
// Try to login with saved credentials
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
|
let now = now_unix_secs();
|
||||||
match client.login(&creds.email, &creds.password).await {
|
match client.login(&creds.email, &creds.password).await {
|
||||||
Ok(session) => Ok(LoginResponse {
|
Ok(session) => {
|
||||||
success: true,
|
if let Ok(store) = subscription_state.lock() {
|
||||||
user_name: Some(session.display_name),
|
let _ = store.mark_valid(now);
|
||||||
subscription: Some(session.subscription_label),
|
}
|
||||||
error: None,
|
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) => {
|
Err(e) => {
|
||||||
// Credentials might be invalid, but don't clear them automatically
|
// Credentials might be invalid, but don't clear them automatically
|
||||||
// Let the user decide
|
// Let the user decide
|
||||||
@@ -136,7 +261,9 @@ pub async fn auto_login(state: State<'_, AppState>) -> Result<LoginResponse, Str
|
|||||||
success: false,
|
success: false,
|
||||||
user_name: None,
|
user_name: None,
|
||||||
subscription: None,
|
subscription: None,
|
||||||
|
subscription_valid_until: None,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
error_code: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod audio_settings;
|
|||||||
pub mod download_settings;
|
pub mod download_settings;
|
||||||
pub mod playback_preferences;
|
pub mod playback_preferences;
|
||||||
pub mod favorites_preferences;
|
pub mod favorites_preferences;
|
||||||
|
pub mod subscription_state;
|
||||||
|
|
||||||
pub use audio_settings::{
|
pub use audio_settings::{
|
||||||
AudioSettings,
|
AudioSettings,
|
||||||
@@ -47,3 +48,10 @@ pub use favorites_preferences::{
|
|||||||
get_favorites_preferences,
|
get_favorites_preferences,
|
||||||
save_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>,
|
library_state: State<'_, crate::library::commands::LibraryState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("Command: clear_download_cache");
|
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;
|
let db = cache_state.db.lock().await;
|
||||||
|
|
||||||
// Get all file paths and clear DB
|
// Get all file paths and clear DB
|
||||||
@@ -391,7 +398,8 @@ pub async fn clear_download_cache(
|
|||||||
|
|
||||||
// Remove all Qobuz downloads from library
|
// Remove all Qobuz downloads from library
|
||||||
let library_db = library_state.db.lock().await;
|
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))?;
|
.map_err(|e| format!("Failed to remove downloads from library: {}", e))?;
|
||||||
log::info!("Removed {} Qobuz downloads from library", removed_count);
|
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
|
//! This application uses the Qobuz API but is not certified by Qobuz.
|
||||||
//! who need bit-perfect playback without browser sample rate limitations.
|
|
||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod api_cache;
|
pub mod api_cache;
|
||||||
@@ -187,6 +186,9 @@ pub fn run() {
|
|||||||
// Initialize favorites preferences state
|
// Initialize favorites preferences state
|
||||||
let favorites_prefs_state = config::favorites_preferences::FavoritesPreferencesState::new()
|
let favorites_prefs_state = config::favorites_preferences::FavoritesPreferencesState::new()
|
||||||
.expect("Failed to initialize favorites preferences");
|
.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
|
// Read saved audio device and settings for player initialization
|
||||||
let (saved_device, audio_settings) = audio_settings_state
|
let (saved_device, audio_settings) = audio_settings_state
|
||||||
@@ -229,6 +231,60 @@ pub fn run() {
|
|||||||
.media_controls
|
.media_controls
|
||||||
.init(app.handle().clone());
|
.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
|
// Start background task to emit playback events
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
let player_state = app.state::<AppState>().player.state.clone();
|
let player_state = app.state::<AppState>().player.state.clone();
|
||||||
@@ -310,6 +366,7 @@ pub fn run() {
|
|||||||
.manage(session_store_state)
|
.manage(session_store_state)
|
||||||
.manage(audio_settings_state)
|
.manage(audio_settings_state)
|
||||||
.manage(download_settings_state)
|
.manage(download_settings_state)
|
||||||
|
.manage(subscription_state)
|
||||||
.manage(offline_state)
|
.manage(offline_state)
|
||||||
.manage(playback_prefs_state)
|
.manage(playback_prefs_state)
|
||||||
.manage(favorites_prefs_state)
|
.manage(favorites_prefs_state)
|
||||||
@@ -556,6 +613,7 @@ pub fn run() {
|
|||||||
download_cache::commands::sync_downloads_to_library,
|
download_cache::commands::sync_downloads_to_library,
|
||||||
// Lyrics commands
|
// Lyrics commands
|
||||||
lyrics::commands::lyrics_get,
|
lyrics::commands::lyrics_get,
|
||||||
|
lyrics::commands::lyrics_get_cache_stats,
|
||||||
lyrics::commands::lyrics_clear_cache,
|
lyrics::commands::lyrics_clear_cache,
|
||||||
// Recommendation store commands
|
// Recommendation store commands
|
||||||
reco_store::commands::reco_log_event,
|
reco_store::commands::reco_log_event,
|
||||||
|
|||||||
@@ -131,4 +131,12 @@ impl LyricsCacheDb {
|
|||||||
.map_err(|e| format!("Failed to clear lyrics cache: {}", e))?;
|
.map_err(|e| format!("Failed to clear lyrics cache: {}", e))?;
|
||||||
Ok(())
|
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
|
//! Tauri commands for lyrics
|
||||||
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use super::{build_cache_key, LyricsPayload, LyricsState};
|
use super::{build_cache_key, LyricsPayload, LyricsState};
|
||||||
use super::providers::{fetch_lrclib, fetch_lyrics_ovh};
|
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;
|
let db = state.db.lock().await;
|
||||||
db.clear()
|
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"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"category": "Music",
|
"category": "Music",
|
||||||
"shortDescription": "Native Qobuz client for Linux",
|
"shortDescription": "Native high-fidelity client for Qobuz\u2122 subscribers on Linux",
|
||||||
"longDescription": "QBZ is a native Qobuz streaming client for Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations.",
|
"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": {
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": [],
|
"depends": [],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import { getName, getVersion } from '@tauri-apps/api/app';
|
import { getName, getVersion } from '@tauri-apps/api/app';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import QobuzLegalNotice from '$lib/components/QobuzLegalNotice.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -63,10 +64,12 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="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.
|
bit-perfect Hi-Fi playback without browser sample rate limitations.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<QobuzLegalNotice showCheckbox={false} />
|
||||||
|
|
||||||
<!-- Links -->
|
<!-- Links -->
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<button class="link-btn" onclick={() => handleOpenUrl('https://github.com/vicrodh/qbz')}>
|
<button class="link-btn" onclick={() => handleOpenUrl('https://github.com/vicrodh/qbz')}>
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
<h3>Acknowledgments</h3>
|
<h3>Acknowledgments</h3>
|
||||||
<div class="attributions">
|
<div class="attributions">
|
||||||
<div class="attribution">
|
<div class="attribution">
|
||||||
<strong>Qobuz</strong> — Music streaming service and API
|
<strong>Qobuz™</strong> — Music streaming service and API
|
||||||
</div>
|
</div>
|
||||||
<div class="attribution">
|
<div class="attribution">
|
||||||
<strong>Tauri</strong> — Application framework (MIT/Apache-2.0)
|
<strong>Tauri</strong> — Application framework (MIT/Apache-2.0)
|
||||||
|
|||||||
@@ -288,7 +288,7 @@
|
|||||||
{#if onShareQobuz}
|
{#if onShareQobuz}
|
||||||
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
||||||
<Link size={14} />
|
<Link size={14} />
|
||||||
<span>Qobuz link</span>
|
<span>Qobuz™ link</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onShareSonglink}
|
{#if onShareSonglink}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
<span class="section-label">
|
<span class="section-label">
|
||||||
{section.label}
|
{section.label}
|
||||||
{#if section.source === 'qobuz'}
|
{#if section.source === 'qobuz'}
|
||||||
<span class="source-badge qobuz">Qobuz</span>
|
<span class="source-badge qobuz">Qobuz™</span>
|
||||||
{:else if section.source === 'ml'}
|
{:else if section.source === 'ml'}
|
||||||
<span class="source-badge ml">ML</span>
|
<span class="source-badge ml">ML</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
This process will:
|
This process will:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<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>Add proper tags to your FLAC files</li>
|
||||||
<li>Organize files into Artist/Album folders</li>
|
<li>Organize files into Artist/Album folders</li>
|
||||||
<li>Embed album artwork</li>
|
<li>Embed album artwork</li>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { X, CloudOff } from 'lucide-svelte';
|
import { X, CloudOff } from 'lucide-svelte';
|
||||||
import { showToast } from '$lib/stores/toastStore';
|
import { showToast } from '$lib/stores/toastStore';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import QobuzLegalNotice from '$lib/components/QobuzLegalNotice.svelte';
|
||||||
|
import { qobuzTosAccepted } from '$lib/stores/qobuzLegalStore';
|
||||||
import {
|
import {
|
||||||
subscribe as subscribeOffline,
|
subscribe as subscribeOffline,
|
||||||
isOffline as checkIsOffline
|
isOffline as checkIsOffline
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
|
|
||||||
const detectedProvider = $derived(detectProvider(url));
|
const detectedProvider = $derived(detectProvider(url));
|
||||||
const activeProvider = $derived(lockedProvider ?? detectedProvider);
|
const activeProvider = $derived(lockedProvider ?? detectedProvider);
|
||||||
const isValid = $derived(!!detectedProvider && !isOffline);
|
const isValid = $derived(!!detectedProvider && !isOffline && $qobuzTosAccepted);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -135,7 +137,7 @@
|
|||||||
const preview = await invoke<ImportPlaylist>('playlist_import_preview', { url });
|
const preview = await invoke<ImportPlaylist>('playlist_import_preview', { url });
|
||||||
pushLog(`Found ${preview.tracks.length} tracks from ${formatProvider(preview.provider)}.`);
|
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', {
|
const result = await invoke<ImportSummary>('playlist_import_execute', {
|
||||||
url,
|
url,
|
||||||
nameOverride: null,
|
nameOverride: null,
|
||||||
@@ -148,7 +150,7 @@
|
|||||||
pushLog(`Imported ${result.matched_tracks} of ${result.total_tracks} tracks into QBZ.`, 'success');
|
pushLog(`Imported ${result.matched_tracks} of ${result.total_tracks} tracks into QBZ.`, 'success');
|
||||||
|
|
||||||
if (result.qobuz_playlist_id) {
|
if (result.qobuz_playlist_id) {
|
||||||
pushLog('Playlist created in Qobuz.', 'success');
|
pushLog('Playlist created in Qobuz™.', 'success');
|
||||||
} else {
|
} else {
|
||||||
pushLog('No matching tracks found.', 'error');
|
pushLog('No matching tracks found.', 'error');
|
||||||
}
|
}
|
||||||
@@ -202,7 +204,7 @@
|
|||||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-title">
|
<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>
|
<h2>Import Playlist</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={onClose}>
|
<button class="close-btn" onclick={onClose}>
|
||||||
@@ -211,6 +213,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<QobuzLegalNotice checkboxDisabled={loading} />
|
||||||
|
|
||||||
{#if isOffline}
|
{#if isOffline}
|
||||||
<div class="offline-warning" role="alert" aria-live="polite">
|
<div class="offline-warning" role="alert" aria-live="polite">
|
||||||
<CloudOff size={16} aria-hidden="true" />
|
<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,
|
onAboutClick,
|
||||||
onLogout,
|
onLogout,
|
||||||
userName = 'User',
|
userName = 'User',
|
||||||
subscription = 'Qobuz'
|
subscription = 'Qobuz™'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let userPlaylists = $state<Playlist[]>([]);
|
let userPlaylists = $state<Playlist[]>([]);
|
||||||
|
|||||||
@@ -332,7 +332,7 @@
|
|||||||
{#if onShareQobuz}
|
{#if onShareQobuz}
|
||||||
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
||||||
<Link size={14} />
|
<Link size={14} />
|
||||||
<span>Qobuz link</span>
|
<span>Qobuz™ link</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onShareSonglink}
|
{#if onShareSonglink}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
import TitleBar from '../TitleBar.svelte';
|
import TitleBar from '../TitleBar.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { setManualOffline } from '$lib/stores/offlineStore';
|
import { setManualOffline } from '$lib/stores/offlineStore';
|
||||||
|
import { qobuzTosAccepted } from '$lib/stores/qobuzLegalStore';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
userName: string;
|
userName: string;
|
||||||
subscription: string;
|
subscription: string;
|
||||||
|
subscriptionValidUntil?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
let rememberMe = $state(true);
|
let rememberMe = $state(true);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isInitializing = $state(true);
|
let isInitializing = $state(true);
|
||||||
let initStatus = $state('Connecting to Qobuz...');
|
let initStatus = $state('Connecting to Qobuz™...');
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let initError = $state<string | null>(null);
|
let initError = $state<string | null>(null);
|
||||||
let isTimedOut = $state(false);
|
let isTimedOut = $state(false);
|
||||||
@@ -29,7 +32,7 @@
|
|||||||
|
|
||||||
const LOGIN_TIMEOUT_MS = 60000; // 60 seconds
|
const LOGIN_TIMEOUT_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
// Initialize the Qobuz client on mount
|
// Initialize the Qobuz™ client on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
initializeClient();
|
initializeClient();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
isInitializing = true;
|
isInitializing = true;
|
||||||
initError = null;
|
initError = null;
|
||||||
isTimedOut = false;
|
isTimedOut = false;
|
||||||
initStatus = 'Connecting to Qobuz...';
|
initStatus = 'Connecting to Qobuz™...';
|
||||||
|
|
||||||
// Start timeout timer
|
// Start timeout timer
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
@@ -67,11 +70,15 @@
|
|||||||
const loggedIn = await invoke<boolean>('is_logged_in');
|
const loggedIn = await invoke<boolean>('is_logged_in');
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
clearTimeoutTimer();
|
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) {
|
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 {
|
} else {
|
||||||
onLoginSuccess({ userName: 'Qobuz User', subscription: 'Active' });
|
onLoginSuccess({ userName: 'User', subscription: 'Active' });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,25 +87,31 @@
|
|||||||
initStatus = 'Checking saved credentials...';
|
initStatus = 'Checking saved credentials...';
|
||||||
const hasSavedCreds = await invoke<boolean>('has_saved_credentials');
|
const hasSavedCreds = await invoke<boolean>('has_saved_credentials');
|
||||||
|
|
||||||
if (hasSavedCreds) {
|
if (hasSavedCreds && get(qobuzTosAccepted)) {
|
||||||
initStatus = 'Logging in...';
|
initStatus = 'Logging in...';
|
||||||
const response = await invoke<{
|
const response = await invoke<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user_name?: string;
|
user_name?: string;
|
||||||
subscription?: string;
|
subscription?: string;
|
||||||
|
subscription_valid_until?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
error_code?: string;
|
||||||
}>('auto_login');
|
}>('auto_login');
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
clearTimeoutTimer();
|
clearTimeoutTimer();
|
||||||
console.log('Auto-login successful');
|
console.log('Auto-login successful');
|
||||||
onLoginSuccess({
|
onLoginSuccess({
|
||||||
userName: response.user_name || 'Qobuz User',
|
userName: response.user_name || 'User',
|
||||||
subscription: response.subscription || 'Active'
|
subscription: response.subscription || 'Active',
|
||||||
|
subscriptionValidUntil: response.subscription_valid_until ?? null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.log('Auto-login failed:', response.error);
|
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
|
// Don't show error, just fall through to manual login
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +144,11 @@
|
|||||||
async function handleLogin(e: Event) {
|
async function handleLogin(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!get(qobuzTosAccepted)) {
|
||||||
|
error = $t('legal.tosRequiredToLogin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
error = 'Please enter email and password';
|
error = 'Please enter email and password';
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +162,9 @@
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
user_name?: string;
|
user_name?: string;
|
||||||
subscription?: string;
|
subscription?: string;
|
||||||
|
subscription_valid_until?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
error_code?: string;
|
||||||
}>('login', { email, password });
|
}>('login', { email, password });
|
||||||
|
|
||||||
console.log('Login response:', response);
|
console.log('Login response:', response);
|
||||||
@@ -162,11 +182,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLoginSuccess({
|
onLoginSuccess({
|
||||||
userName: response.user_name || 'Qobuz User',
|
userName: response.user_name || 'User',
|
||||||
subscription: response.subscription || 'Active'
|
subscription: response.subscription || 'Active',
|
||||||
});
|
subscriptionValidUntil: response.subscription_valid_until ?? null,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
error = response.error || 'Login failed';
|
if (response.error_code === 'ineligible_user') {
|
||||||
|
error = $t('auth.ineligibleSubscription');
|
||||||
|
} else {
|
||||||
|
error = response.error || 'Login failed';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
@@ -194,15 +219,13 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="/logo.png" alt="QBZ Logo" class="logo-img" />
|
<img src="/logo.png" alt="QBZ Logo" class="logo-img" />
|
||||||
<h1>QBZ</h1>
|
|
||||||
<p class="subtitle">Native Qobuz Client for Linux</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isTimedOut}
|
{#if isTimedOut}
|
||||||
<div class="timeout-box">
|
<div class="timeout-box">
|
||||||
<p class="timeout-title">Connection is taking too long</p>
|
<p class="timeout-title">Connection is taking too long</p>
|
||||||
<p class="timeout-detail">
|
<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>
|
</p>
|
||||||
<div class="timeout-actions">
|
<div class="timeout-actions">
|
||||||
<button class="retry-btn" onclick={handleRetryLogin}>Try Again</button>
|
<button class="retry-btn" onclick={handleRetryLogin}>Try Again</button>
|
||||||
@@ -216,69 +239,86 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if initError}
|
{:else if initError}
|
||||||
<div class="error-box">
|
<div class="error-box">
|
||||||
<p>Failed to connect to Qobuz</p>
|
<p>Failed to connect to Qobuz™</p>
|
||||||
<p class="error-detail">{initError}</p>
|
<p class="error-detail">{initError}</p>
|
||||||
<button class="retry-btn" onclick={initializeClient}>Retry</button>
|
<button class="retry-btn" onclick={initializeClient}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={handleLogin}>
|
<div class="login-body">
|
||||||
<div class="input-group">
|
<form onsubmit={handleLogin}>
|
||||||
<label for="email">Email</label>
|
<div class="input-group">
|
||||||
<input
|
<label for="email">Email</label>
|
||||||
id="email"
|
<input
|
||||||
type="email"
|
id="email"
|
||||||
bind:value={email}
|
type="email"
|
||||||
placeholder="your@email.com"
|
bind:value={email}
|
||||||
disabled={isLoading}
|
placeholder="your@email.com"
|
||||||
autocomplete="email"
|
disabled={isLoading}
|
||||||
/>
|
autocomplete="email"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="remember-me">
|
<div class="remember-me">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" bind:checked={rememberMe} />
|
<input type="checkbox" bind:checked={rememberMe} />
|
||||||
<span>Remember me</span>
|
<span>Remember me</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
<div class="remember-me tos-remember">
|
||||||
<div class="error-message">{error}</div>
|
<label>
|
||||||
{/if}
|
<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 error}
|
||||||
{#if isLoading}
|
<div class="error-message">{error}</div>
|
||||||
<div class="spinner small"></div>
|
|
||||||
<span>Signing in...</span>
|
|
||||||
{:else}
|
|
||||||
<span>Sign in with Qobuz</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="disclaimer">
|
<button type="submit" class="login-btn" disabled={isLoading || !$qobuzTosAccepted}>
|
||||||
QBZ requires an active Qobuz subscription.
|
{#if isLoading}
|
||||||
Your credentials are sent directly to Qobuz and stored securely in your system's keyring.
|
<div class="spinner small"></div>
|
||||||
</p>
|
<span>Signing in...</span>
|
||||||
|
{:else}
|
||||||
<p class="offline-link">
|
<span>Sign in with Qobuz™</span>
|
||||||
<small>
|
{/if}
|
||||||
<button type="button" class="link-button" onclick={handleStartOffline}>
|
|
||||||
{$t('offline.startWithoutLogin')}
|
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,14 +340,19 @@
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 720px;
|
||||||
padding: 48px;
|
padding: 52px;
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
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 {
|
.logo {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -321,17 +366,33 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo h1 {
|
.login-body {
|
||||||
font-size: 24px;
|
flex: 1;
|
||||||
font-weight: 700;
|
display: flex;
|
||||||
color: var(--text-primary);
|
flex-direction: column;
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.login-footer {
|
||||||
font-size: 14px;
|
margin-top: auto;
|
||||||
color: var(--text-muted);
|
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 {
|
.initializing {
|
||||||
@@ -524,16 +585,16 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer {
|
.footer-copy {
|
||||||
margin-top: 24px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.offline-link {
|
.offline-link {
|
||||||
margin-top: 16px;
|
margin-top: 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
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 Toggle from '../Toggle.svelte';
|
||||||
import Dropdown from '../Dropdown.svelte';
|
import Dropdown from '../Dropdown.svelte';
|
||||||
import VolumeSlider from '../VolumeSlider.svelte';
|
import VolumeSlider from '../VolumeSlider.svelte';
|
||||||
import {
|
import {
|
||||||
getDownloadCacheStats,
|
getDownloadCacheStats,
|
||||||
clearDownloadCache,
|
clearDownloadCache,
|
||||||
openDownloadCacheFolder,
|
|
||||||
type DownloadCacheStats
|
type DownloadCacheStats
|
||||||
} from '$lib/stores/downloadState';
|
} from '$lib/stores/downloadState';
|
||||||
import { notifyDownloadSettingsChanged } from '$lib/stores/downloadSettingsStore';
|
import { notifyDownloadSettingsChanged } from '$lib/stores/downloadSettingsStore';
|
||||||
@@ -58,6 +57,7 @@
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
subscription?: string;
|
subscription?: string;
|
||||||
|
subscriptionValidUntil?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
@@ -80,7 +80,14 @@
|
|||||||
is_active: boolean;
|
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)
|
// Cache state (memory cache)
|
||||||
let cacheStats = $state<CacheStats | null>(null);
|
let cacheStats = $state<CacheStats | null>(null);
|
||||||
@@ -93,6 +100,7 @@
|
|||||||
|
|
||||||
// Lyrics cache state
|
// Lyrics cache state
|
||||||
let isClearingLyrics = $state(false);
|
let isClearingLyrics = $state(false);
|
||||||
|
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
|
||||||
|
|
||||||
// Migration state
|
// Migration state
|
||||||
let showMigrationModal = $state(false);
|
let showMigrationModal = $state(false);
|
||||||
@@ -117,7 +125,6 @@
|
|||||||
let librarySection: HTMLElement;
|
let librarySection: HTMLElement;
|
||||||
let integrationsSection: HTMLElement;
|
let integrationsSection: HTMLElement;
|
||||||
let storageSection: HTMLElement;
|
let storageSection: HTMLElement;
|
||||||
let lyricsSection: HTMLElement;
|
|
||||||
let activeSection = $state('audio');
|
let activeSection = $state('audio');
|
||||||
|
|
||||||
// Navigation section definitions (static, refs resolved at click/scroll time)
|
// Navigation section definitions (static, refs resolved at click/scroll time)
|
||||||
@@ -130,7 +137,6 @@
|
|||||||
{ id: 'library', label: 'Library' },
|
{ id: 'library', label: 'Library' },
|
||||||
{ id: 'integrations', label: 'Integrations' },
|
{ id: 'integrations', label: 'Integrations' },
|
||||||
{ id: 'storage', label: 'Storage' },
|
{ id: 'storage', label: 'Storage' },
|
||||||
{ id: 'lyrics', label: 'Lyrics' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get section element by id (resolved at call time, not definition time)
|
// Get section element by id (resolved at call time, not definition time)
|
||||||
@@ -144,7 +150,6 @@
|
|||||||
case 'library': return librarySection;
|
case 'library': return librarySection;
|
||||||
case 'integrations': return integrationsSection;
|
case 'integrations': return integrationsSection;
|
||||||
case 'storage': return storageSection;
|
case 'storage': return storageSection;
|
||||||
case 'lyrics': return lyricsSection;
|
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +371,6 @@
|
|||||||
// Library settings
|
// Library settings
|
||||||
let fetchQobuzArtistImages = $state(true);
|
let fetchQobuzArtistImages = $state(true);
|
||||||
let showQobuzDownloadsInLibrary = $state(false);
|
let showQobuzDownloadsInLibrary = $state(false);
|
||||||
let downloadRoot = $state('');
|
|
||||||
|
|
||||||
// Last.fm integration state
|
// Last.fm integration state
|
||||||
let lastfmConnected = $state(false);
|
let lastfmConnected = $state(false);
|
||||||
@@ -431,6 +435,9 @@
|
|||||||
// Load download cache stats
|
// Load download cache stats
|
||||||
loadDownloadStats();
|
loadDownloadStats();
|
||||||
|
|
||||||
|
// Load lyrics cache stats
|
||||||
|
loadLyricsCacheStats();
|
||||||
|
|
||||||
// Load audio devices first (includes PipeWire sinks), then settings
|
// Load audio devices first (includes PipeWire sinks), then settings
|
||||||
// Also load backends and ALSA plugins
|
// Also load backends and ALSA plugins
|
||||||
Promise.all([
|
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() {
|
async function loadDownloadStats() {
|
||||||
try {
|
try {
|
||||||
downloadStats = await getDownloadCacheStats();
|
downloadStats = await getDownloadCacheStats();
|
||||||
@@ -1158,7 +1175,6 @@
|
|||||||
try {
|
try {
|
||||||
const settings = await invoke<{download_root: string, show_in_library: boolean}>('get_download_settings');
|
const settings = await invoke<{download_root: string, show_in_library: boolean}>('get_download_settings');
|
||||||
showQobuzDownloadsInLibrary = settings.show_in_library;
|
showQobuzDownloadsInLibrary = settings.show_in_library;
|
||||||
downloadRoot = settings.download_root;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load download settings:', err);
|
console.error('Failed to load download settings:', err);
|
||||||
}
|
}
|
||||||
@@ -1220,78 +1236,6 @@
|
|||||||
loadDownloadStats();
|
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() {
|
async function handleRepairDownloads() {
|
||||||
if (isRepairingDownloads) return;
|
if (isRepairingDownloads) return;
|
||||||
isRepairingDownloads = true;
|
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() {
|
async function handleClearCache() {
|
||||||
if (isClearing) return;
|
if (isClearing) return;
|
||||||
isClearing = true;
|
isClearing = true;
|
||||||
@@ -1358,6 +1294,7 @@
|
|||||||
try {
|
try {
|
||||||
await clearLyricsCache();
|
await clearLyricsCache();
|
||||||
console.log('Lyrics cache cleared');
|
console.log('Lyrics cache cleared');
|
||||||
|
await loadLyricsCacheStats();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear lyrics cache:', err);
|
console.error('Failed to clear lyrics cache:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1433,6 +1370,11 @@
|
|||||||
<div class="email">{userEmail}</div>
|
<div class="email">{userEmail}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="subscription">{subscription}</div>
|
<div class="subscription">{subscription}</div>
|
||||||
|
{#if subscriptionValidUntil}
|
||||||
|
<div class="subscription-until">
|
||||||
|
{$t('settings.account.validUntil', { values: { date: subscriptionValidUntil } })}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button class="logout-btn" onclick={onLogout}>{$t('settings.account.logout')}</button>
|
<button class="logout-btn" onclick={onLogout}>{$t('settings.account.logout')}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1726,19 +1668,10 @@
|
|||||||
|
|
||||||
<!-- Downloads Section -->
|
<!-- Downloads Section -->
|
||||||
<section class="section" bind:this={downloadsSection}>
|
<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-row">
|
||||||
<div class="setting-with-description">
|
<span class="setting-label">{$t('settings.downloads.downloadedTracks')}</span>
|
||||||
<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-value">
|
<span class="setting-value">
|
||||||
{#if downloadStats}
|
{#if downloadStats}
|
||||||
{downloadStats.readyTracks} tracks ({formatBytes(downloadStats.totalSizeBytes)})
|
{downloadStats.readyTracks} tracks ({formatBytes(downloadStats.totalSizeBytes)})
|
||||||
@@ -1750,7 +1683,7 @@
|
|||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-with-description">
|
<div class="setting-with-description">
|
||||||
<span class="setting-label">Show in Local Library</span>
|
<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>
|
</div>
|
||||||
<Toggle enabled={showQobuzDownloadsInLibrary} onchange={handleShowDownloadsChange} />
|
<Toggle enabled={showQobuzDownloadsInLibrary} onchange={handleShowDownloadsChange} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1767,7 +1700,7 @@
|
|||||||
{isRepairingDownloads ? 'Repairing...' : 'Repair'}
|
{isRepairingDownloads ? 'Repairing...' : 'Repair'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row last">
|
||||||
<span class="setting-label">Clear Downloads</span>
|
<span class="setting-label">Clear Downloads</span>
|
||||||
<button
|
<button
|
||||||
class="clear-btn"
|
class="clear-btn"
|
||||||
@@ -1777,17 +1710,6 @@
|
|||||||
{isClearingDownloads ? 'Clearing...' : 'Clear All'}
|
{isClearingDownloads ? 'Clearing...' : 'Clear All'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Library Section -->
|
<!-- Library Section -->
|
||||||
@@ -1900,28 +1822,24 @@
|
|||||||
<!-- Storage Section (Memory Cache) -->
|
<!-- Storage Section (Memory Cache) -->
|
||||||
<section class="section" bind:this={storageSection}>
|
<section class="section" bind:this={storageSection}>
|
||||||
<h3 class="section-title">{$t('settings.storage.title')}</h3>
|
<h3 class="section-title">{$t('settings.storage.title')}</h3>
|
||||||
|
<p class="section-note">{$t('settings.storage.queueCacheNote')}</p>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<span class="setting-label">{$t('settings.storage.cacheSize')}</span>
|
<div class="setting-info">
|
||||||
<span class="setting-value">
|
<span class="setting-label">{$t('settings.storage.clearCache')}</span>
|
||||||
{#if cacheStats}
|
<small class="setting-note">
|
||||||
{formatBytes(cacheStats.current_size_bytes)} / {formatBytes(cacheStats.max_size_bytes)}
|
{#if cacheStats}
|
||||||
{:else}
|
{$t('settings.storage.queueCacheStats', {
|
||||||
{$t('actions.loading')}
|
values: {
|
||||||
{/if}
|
tracks: cacheStats.cached_tracks,
|
||||||
</span>
|
used: formatBytes(cacheStats.current_size_bytes),
|
||||||
</div>
|
max: formatBytes(cacheStats.max_size_bytes)
|
||||||
<div class="setting-row">
|
}
|
||||||
<span class="setting-label">{$t('settings.storage.cachedTracks')}</span>
|
})}
|
||||||
<span class="setting-value">
|
{:else}
|
||||||
{#if cacheStats}
|
{$t('actions.loading')}
|
||||||
{cacheStats.cached_tracks} {$t('album.tracks')}
|
{/if}
|
||||||
{:else}
|
</small>
|
||||||
-
|
</div>
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row last">
|
|
||||||
<span class="setting-label">{$t('settings.storage.clearCache')}</span>
|
|
||||||
<button
|
<button
|
||||||
class="clear-btn"
|
class="clear-btn"
|
||||||
onclick={handleClearCache}
|
onclick={handleClearCache}
|
||||||
@@ -1930,17 +1848,22 @@
|
|||||||
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
|
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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
|
<button
|
||||||
class="clear-btn"
|
class="clear-btn"
|
||||||
onclick={handleClearLyricsCache}
|
onclick={handleClearLyricsCache}
|
||||||
@@ -2146,6 +2069,13 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-note {
|
||||||
|
margin: -6px 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.account-card {
|
.account-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2186,6 +2116,12 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscription-until {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
padding: 8px 24px;
|
padding: 8px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -2208,6 +2144,7 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-bottom: 1px solid var(--bg-tertiary);
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.last {
|
.setting-row.last {
|
||||||
@@ -2223,6 +2160,8 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
flex: 0 1 60%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-description {
|
.setting-description {
|
||||||
@@ -2235,6 +2174,8 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
max-width: 60%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-desc {
|
.setting-desc {
|
||||||
@@ -2255,6 +2196,13 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
padding: 12px 0;
|
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) {
|
.setting-row:has(.setting-desc) {
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
@@ -2391,6 +2339,20 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
background-color: var(--bg-hover);
|
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 */
|
/* Last.fm styles */
|
||||||
.lastfm-connected {
|
.lastfm-connected {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"name": "QBZ",
|
"name": "QBZ",
|
||||||
"tagline": "Hi-Fi Qobuz Client for Linux"
|
"tagline": "Hi-Fi streaming client for Linux"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"recentSearches": "Recent Searches",
|
"recentSearches": "Recent Searches",
|
||||||
"clearHistory": "Clear History",
|
"clearHistory": "Clear History",
|
||||||
"startTyping": "Start typing to search Qobuz",
|
"startTyping": "Start typing to search Qobuz™",
|
||||||
"noAlbumsFor": "No albums found for \"{query}\"",
|
"noAlbumsFor": "No albums found for \"{query}\"",
|
||||||
"noTracksFor": "No tracks found for \"{query}\"",
|
"noTracksFor": "No tracks found for \"{query}\"",
|
||||||
"noArtistsFor": "No artists found for \"{query}\""
|
"noArtistsFor": "No artists found for \"{query}\""
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"renamePlaylist": "Rename Playlist",
|
"renamePlaylist": "Rename Playlist",
|
||||||
"allPlaylists": "All Playlists",
|
"allPlaylists": "All Playlists",
|
||||||
"myPlaylists": "My Playlists",
|
"myPlaylists": "My Playlists",
|
||||||
"qobuzPlaylists": "Qobuz Playlists",
|
"qobuzPlaylists": "Qobuz™ Playlists",
|
||||||
"emptyPlaylist": "This playlist is empty",
|
"emptyPlaylist": "This playlist is empty",
|
||||||
"addTracks": "Add Tracks",
|
"addTracks": "Add Tracks",
|
||||||
"removeTracks": "Remove",
|
"removeTracks": "Remove",
|
||||||
@@ -307,6 +307,7 @@
|
|||||||
"title": "Account",
|
"title": "Account",
|
||||||
"loggedInAs": "Logged in as",
|
"loggedInAs": "Logged in as",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
|
"validUntil": "Valid until {date}",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"login": "Login"
|
"login": "Login"
|
||||||
},
|
},
|
||||||
@@ -349,13 +350,13 @@
|
|||||||
"accentColor": "Accent Color",
|
"accentColor": "Accent Color",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"uiScale": "UI Scale",
|
"uiScale": "UI Scale",
|
||||||
"inAppToasts": "In-app Toasts",
|
"inAppToasts": "In-app toasts notifications",
|
||||||
"systemNotifications": "System Notifications"
|
"systemNotifications": "System Notifications"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"title": "Library",
|
"title": "Library",
|
||||||
"fetchArtistImages": "Fetch Qobuz Artist Images",
|
"fetchArtistImages": "Fetch Qobuz™ Artist Images",
|
||||||
"fetchArtistImagesDesc": "Show artist photos from Qobuz in local library"
|
"fetchArtistImagesDesc": "Show artist photos from Qobuz™ in local library"
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integrations",
|
"title": "Integrations",
|
||||||
@@ -373,13 +374,16 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Storage",
|
"title": "Storage",
|
||||||
"cachedTracks": "Cached Tracks",
|
"queueCacheNote": "Queue list storage. Clear if you have playback issues. (This is not the Downloads cache.)",
|
||||||
"cacheSize": "Cache Size",
|
"queueCacheStats": "{tracks} tracks using {used} / {max}",
|
||||||
"clearCache": "Clear Cache",
|
"cachedTracks": "Cached Queue Tracks",
|
||||||
|
"cacheSize": "Queue Cache Size",
|
||||||
|
"clearCache": "Clear queue cache",
|
||||||
"clearing": "Clearing..."
|
"clearing": "Clearing..."
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"title": "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",
|
"downloadedTracks": "Downloaded Tracks",
|
||||||
"clearDownloads": "Clear All",
|
"clearDownloads": "Clear All",
|
||||||
"openFolder": "Open Folder"
|
"openFolder": "Open Folder"
|
||||||
@@ -389,7 +393,8 @@
|
|||||||
"lyrics": {
|
"lyrics": {
|
||||||
"title": "Lyrics",
|
"title": "Lyrics",
|
||||||
"lyricsCache": "Lyrics Cache",
|
"lyricsCache": "Lyrics Cache",
|
||||||
"clearLyrics": "Clear Lyrics"
|
"clearLyrics": "Clear Lyrics Cache",
|
||||||
|
"cacheStats": "{entries} entries using {size}"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
@@ -407,7 +412,15 @@
|
|||||||
"loggingIn": "Signing in...",
|
"loggingIn": "Signing in...",
|
||||||
"loginError": "Login failed. Please check your credentials.",
|
"loginError": "Login failed. Please check your credentials.",
|
||||||
"welcomeBack": "Welcome back",
|
"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": {
|
"notifications": {
|
||||||
"trackAdded": "Track added to queue",
|
"trackAdded": "Track added to queue",
|
||||||
@@ -478,11 +491,11 @@
|
|||||||
},
|
},
|
||||||
"offline": {
|
"offline": {
|
||||||
"title": "Offline Mode",
|
"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",
|
"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",
|
"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",
|
"manualMode": "Offline Mode Enabled",
|
||||||
"manualModeMessage": "You have manually enabled offline mode. Your local library is still available.",
|
"manualModeMessage": "You have manually enabled offline mode. Your local library is still available.",
|
||||||
"goToLibrary": "Go to Local Library",
|
"goToLibrary": "Go to Local Library",
|
||||||
@@ -499,7 +512,7 @@
|
|||||||
"notAvailable": "Not Available Offline",
|
"notAvailable": "Not Available Offline",
|
||||||
"trackNotAvailable": "This track is not available offline",
|
"trackNotAvailable": "This track is not available offline",
|
||||||
"featureDisabled": "This feature requires internet connection",
|
"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",
|
"noNetworkToast": "No internet connection available",
|
||||||
"checkingNetwork": "Checking network connection...",
|
"checkingNetwork": "Checking network connection...",
|
||||||
"available": "Fully Available Offline",
|
"available": "Fully Available Offline",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"name": "QBZ",
|
"name": "QBZ",
|
||||||
"tagline": "Cliente Hi-Fi de Qobuz para Linux"
|
"tagline": "Cliente Hi-Fi de streaming para Linux"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"searching": "Buscando...",
|
"searching": "Buscando...",
|
||||||
"recentSearches": "Búsquedas Recientes",
|
"recentSearches": "Búsquedas Recientes",
|
||||||
"clearHistory": "Limpiar Historial",
|
"clearHistory": "Limpiar Historial",
|
||||||
"startTyping": "Escribe para buscar en Qobuz",
|
"startTyping": "Escribe para buscar en Qobuz™",
|
||||||
"noAlbumsFor": "No se encontraron álbumes para \"{query}\"",
|
"noAlbumsFor": "No se encontraron álbumes para \"{query}\"",
|
||||||
"noTracksFor": "No se encontraron pistas para \"{query}\"",
|
"noTracksFor": "No se encontraron pistas para \"{query}\"",
|
||||||
"noArtistsFor": "No se encontraron artistas para \"{query}\""
|
"noArtistsFor": "No se encontraron artistas para \"{query}\""
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"renamePlaylist": "Renombrar Lista",
|
"renamePlaylist": "Renombrar Lista",
|
||||||
"allPlaylists": "Todas las Listas",
|
"allPlaylists": "Todas las Listas",
|
||||||
"myPlaylists": "Mis Listas",
|
"myPlaylists": "Mis Listas",
|
||||||
"qobuzPlaylists": "Listas de Qobuz",
|
"qobuzPlaylists": "Listas de Qobuz™",
|
||||||
"emptyPlaylist": "Esta lista está vacía",
|
"emptyPlaylist": "Esta lista está vacía",
|
||||||
"addTracks": "Agregar Pistas",
|
"addTracks": "Agregar Pistas",
|
||||||
"removeTracks": "Eliminar",
|
"removeTracks": "Eliminar",
|
||||||
@@ -307,6 +307,7 @@
|
|||||||
"title": "Cuenta",
|
"title": "Cuenta",
|
||||||
"loggedInAs": "Conectado como",
|
"loggedInAs": "Conectado como",
|
||||||
"subscription": "Suscripción",
|
"subscription": "Suscripción",
|
||||||
|
"validUntil": "Válida hasta {date}",
|
||||||
"logout": "Cerrar Sesión",
|
"logout": "Cerrar Sesión",
|
||||||
"login": "Iniciar Sesión"
|
"login": "Iniciar Sesión"
|
||||||
},
|
},
|
||||||
@@ -354,8 +355,8 @@
|
|||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"title": "Biblioteca",
|
"title": "Biblioteca",
|
||||||
"fetchArtistImages": "Obtener Imágenes de Artistas de Qobuz",
|
"fetchArtistImages": "Obtener imágenes de artistas de Qobuz™",
|
||||||
"fetchArtistImagesDesc": "Mostrar fotos de artistas de Qobuz en biblioteca local"
|
"fetchArtistImagesDesc": "Mostrar fotos de artistas de Qobuz™ en la biblioteca local"
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integraciones",
|
"title": "Integraciones",
|
||||||
@@ -373,13 +374,16 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Almacenamiento",
|
"title": "Almacenamiento",
|
||||||
"cachedTracks": "Pistas en Caché",
|
"queueCacheNote": "Almacenamiento de la cola. Límpialo si tienes problemas de reproducción. (No es el caché de Descargas.)",
|
||||||
"cacheSize": "Tamaño de Caché",
|
"queueCacheStats": "{tracks} pistas usando {used} / {max}",
|
||||||
"clearCache": "Limpiar Caché",
|
"cachedTracks": "Pistas de la cola en caché",
|
||||||
|
"cacheSize": "Tamaño de caché de la cola",
|
||||||
|
"clearCache": "Limpiar caché de la cola",
|
||||||
"clearing": "Limpiando..."
|
"clearing": "Limpiando..."
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"title": "Descargas",
|
"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",
|
"downloadedTracks": "Pistas Descargadas",
|
||||||
"clearDownloads": "Limpiar Todo",
|
"clearDownloads": "Limpiar Todo",
|
||||||
"openFolder": "Abrir Carpeta"
|
"openFolder": "Abrir Carpeta"
|
||||||
@@ -389,7 +393,8 @@
|
|||||||
"lyrics": {
|
"lyrics": {
|
||||||
"title": "Letras",
|
"title": "Letras",
|
||||||
"lyricsCache": "Caché de Letras",
|
"lyricsCache": "Caché de Letras",
|
||||||
"clearLyrics": "Limpiar Letras"
|
"clearLyrics": "Limpiar caché de letras",
|
||||||
|
"cacheStats": "{entries} entradas usando {size}"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "Acerca de",
|
"title": "Acerca de",
|
||||||
@@ -407,7 +412,15 @@
|
|||||||
"loggingIn": "Iniciando sesión...",
|
"loggingIn": "Iniciando sesión...",
|
||||||
"loginError": "Error al iniciar sesión. Verifica tus credenciales.",
|
"loginError": "Error al iniciar sesión. Verifica tus credenciales.",
|
||||||
"welcomeBack": "Bienvenido de nuevo",
|
"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": {
|
"notifications": {
|
||||||
"trackAdded": "Pista agregada a la cola",
|
"trackAdded": "Pista agregada a la cola",
|
||||||
@@ -478,11 +491,11 @@
|
|||||||
},
|
},
|
||||||
"offline": {
|
"offline": {
|
||||||
"title": "Modo 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",
|
"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",
|
"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",
|
"manualMode": "Modo Offline Activado",
|
||||||
"manualModeMessage": "Has activado el modo offline manualmente. Tu biblioteca local sigue disponible.",
|
"manualModeMessage": "Has activado el modo offline manualmente. Tu biblioteca local sigue disponible.",
|
||||||
"goToLibrary": "Ir a Biblioteca Local",
|
"goToLibrary": "Ir a Biblioteca Local",
|
||||||
@@ -499,7 +512,7 @@
|
|||||||
"notAvailable": "No Disponible Offline",
|
"notAvailable": "No Disponible Offline",
|
||||||
"trackNotAvailable": "Esta pista no esta disponible offline",
|
"trackNotAvailable": "Esta pista no esta disponible offline",
|
||||||
"featureDisabled": "Esta funcion requiere conexion a internet",
|
"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",
|
"noNetworkToast": "No hay conexion a internet disponible",
|
||||||
"checkingNetwork": "Verificando conexion a internet...",
|
"checkingNetwork": "Verificando conexion a internet...",
|
||||||
"available": "Totalmente Disponible Offline",
|
"available": "Totalmente Disponible Offline",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
userName: string;
|
userName: string;
|
||||||
subscription: string;
|
subscription: string;
|
||||||
|
subscriptionValidUntil?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth state
|
// 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}
|
onAboutClick={() => isAboutModalOpen = true}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
userName={userInfo?.userName || 'User'}
|
userName={userInfo?.userName || 'User'}
|
||||||
subscription={userInfo?.subscription || 'Qobuz'}
|
subscription={userInfo?.subscription || 'Qobuz™'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Content Area (main + lyrics sidebar) -->
|
<!-- Content Area (main + lyrics sidebar) -->
|
||||||
@@ -2177,6 +2177,7 @@
|
|||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
userName={userInfo?.userName}
|
userName={userInfo?.userName}
|
||||||
subscription={userInfo?.subscription}
|
subscription={userInfo?.subscription}
|
||||||
|
subscriptionValidUntil={userInfo?.subscriptionValidUntil}
|
||||||
/>
|
/>
|
||||||
{:else if activeView === 'album' && selectedAlbum}
|
{:else if activeView === 'album' && selectedAlbum}
|
||||||
<AlbumDetailView
|
<AlbumDetailView
|
||||||
|
|||||||
Reference in new issue
Block a user