feat(slint): Plex creds store + Settings auth/PIN UX (port slices 0-2)

Fresh Slint-only Plex integration foundation (Tauri untouched; the Tauri
code is slated for deletion at port end, so creds are not shared):

- slice 0: fix the false Plex docs in local_library.rs
- slice 1: add the qbz-plex dependency to qbz-slint
- slice 2a: qbz-app/settings/plex.rs per-user store (10 fields, idempotent
  4->11 column migration keeping creds, 8 tests) - frontend-agnostic,
  no tauri::State (ADR-006)
- slice 2b: Settings > Local Library Plex section, 1:1 with the complete
  Tauri feature - master toggle + collapse, LAN-only address gate, PIN auth
  (2500ms poll, self-stopping on section-leave/expiry/disconnect),
  manual-token fallback, ping + machine-id capture, multi-section pick
  (default-all), disconnect vs clear-cache; once-per-session background
  refresh (no cache_clear churn on section re-entry)

cc
This commit is contained in:
vicrodh committed 2026-06-07 21:44:33 -06:00
1 parent 82129cff35
commit f7619a92fc
11 files changed
+2362 -12

No files matched your search

+1
View File
@@ -5471,6 +5471,7 @@ dependencies = [
"qbz-models",
"qbz-offline-cache",
"qbz-player",
"qbz-plex",
"qbz-qobuz",
"qconnect-app",
"qconnect-transport-ws",
+1
View File
@@ -6,6 +6,7 @@ pub mod image_cache;
pub mod legal;
pub mod library;
pub mod playback;
pub mod plex;
pub mod remote_control;
pub mod subscription;
pub mod tray;
+723
View File
@@ -0,0 +1,723 @@
//! Portable Plex credential + library-selection storage.
//!
//! Owns the persisted Plex connection settings for the Slint frontend:
//! the master enable + collapse UI flags, the resolved base URL, auth token,
//! the manual-token toggle, the experimental metadata-write toggle, the list
//! of selected music-library section keys (plus a legacy single-key mirror),
//! the captured `machine_identifier`, and a stable generated `client_id`.
//! Runtime concerns — PIN auth, ping, browse, media resolution — live in the
//! `qbz-plex` core crate and the host application layer.
//!
//! Mirrors the [`super::tray`] store: a single-row SQLite table, opened
//! globally via [`PlexSettingsStore::new`] and re-pointed at the active
//! user's data directory via [`PlexSettingsState::init_at`] at login, so
//! credentials are scoped per Qobuz user (matching the Tauri frontend's
//! per-user `qbz-plex-poc-*` localStorage scoping).
//!
//! NOTE: this is a FRESH Slint-only store. Tauri is intentionally not wired
//! to it — the Tauri code is slated for deletion once the Slint port is
//! complete, so credentials are not shared and the user authenticates Plex
//! once in Slint. See
//! `qbz-nix-docs/plex-integration/2026-06-07-plex-slint-build-spec.md` §2.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlexSettings {
/// Master toggle. When false, the whole Plex section body is hidden.
/// Default OFF — integrations are opt-in.
pub enabled: bool,
/// Collapse chevron state. Body renders only when `enabled && !ui_collapsed`.
pub ui_collapsed: bool,
/// Plex server base URL, e.g. `http://127.0.0.1:32400`. Empty = not
/// configured. This is the RESOLVED `proto://host:port`, not the raw
/// host-form text the user typed.
pub base_url: String,
/// `X-Plex-Token`. Empty = not authenticated.
pub token: String,
/// When true, the user entered the token manually instead of using the
/// PIN flow (controls which Settings affordance is shown).
pub manual_token_mode: bool,
/// Experimental metadata write-back toggle. Persisted but not consumed by
/// slice 2 logic. Default OFF.
pub metadata_write_enabled: bool,
/// The list of picked music-library section keys (Plex `Directory@key`).
/// Empty = none chosen yet (default-select ALL on first fetch).
pub selected_section_keys: Vec<String>,
/// Legacy single-section mirror = first element of `selected_section_keys`.
/// Kept for backward/parallel compat; not the source of truth anymore.
pub selected_section_key: String,
/// `machine_identifier` captured from `plex_ping`; threaded as `server_id`
/// into the cache save calls. Empty until first successful ping.
pub machine_id: String,
/// Generated `qbz-{uuid}` ONCE, persisted, reused for every PIN call.
/// Empty until first authorize.
pub client_id: String,
}
impl PlexSettings {
/// A connection is usable only when both the base URL and token are set.
pub fn is_configured(&self) -> bool {
!self.base_url.is_empty() && !self.token.is_empty()
}
}
pub struct PlexSettingsStore {
conn: Connection,
}
impl PlexSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open Plex settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for Plex settings database: {}", e))?;
// Base (slice 2a) schema. New columns are added by idempotent ALTERs
// below so an existing 4-column 2a DB migrates without dropping creds.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS plex_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
manual_token_mode INTEGER NOT NULL DEFAULT 0,
selected_section_key TEXT NOT NULL DEFAULT ''
);",
)
.map_err(|e| format!("Failed to create Plex settings table: {}", e))?;
// Idempotent ALTER-based migrations for the 6 new columns (mirrors the
// tray.rs migration style: PRAGMA table_info presence check per column).
Self::ensure_column(
&conn,
"enabled",
"ALTER TABLE plex_settings ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;",
)?;
Self::ensure_column(
&conn,
"ui_collapsed",
"ALTER TABLE plex_settings ADD COLUMN ui_collapsed INTEGER NOT NULL DEFAULT 0;",
)?;
Self::ensure_column(
&conn,
"metadata_write_enabled",
"ALTER TABLE plex_settings ADD COLUMN metadata_write_enabled INTEGER NOT NULL DEFAULT 0;",
)?;
Self::ensure_column(
&conn,
"selected_section_keys",
"ALTER TABLE plex_settings ADD COLUMN selected_section_keys TEXT NOT NULL DEFAULT '[]';",
)?;
Self::ensure_column(
&conn,
"machine_id",
"ALTER TABLE plex_settings ADD COLUMN machine_id TEXT NOT NULL DEFAULT '';",
)?;
Self::ensure_column(
&conn,
"client_id",
"ALTER TABLE plex_settings ADD COLUMN client_id TEXT NOT NULL DEFAULT '';",
)?;
conn.execute(
"INSERT OR IGNORE INTO plex_settings
(id, base_url, token, manual_token_mode, selected_section_key,
enabled, ui_collapsed, metadata_write_enabled, selected_section_keys,
machine_id, client_id)
VALUES (1, '', '', 0, '', 0, 0, 0, '[]', '', '')",
[],
)
.map_err(|e| format!("Failed to insert default Plex settings: {}", e))?;
info!("[PlexSettings] Database initialized");
Ok(Self { conn })
}
/// Add `alter_sql`'s column when it is not already present (idempotent).
fn ensure_column(conn: &Connection, name: &str, alter_sql: &str) -> Result<(), String> {
let present: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('plex_settings') WHERE name = ?1",
params![name],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check Plex column '{}': {}", name, e))?;
if present == 0 {
conn.execute_batch(alter_sql)
.map_err(|e| format!("Failed to add Plex column '{}': {}", name, e))?;
}
Ok(())
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "plex_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "plex_settings.db")
}
pub fn get_settings(&self) -> Result<PlexSettings, String> {
self.conn
.query_row(
"SELECT base_url, token, manual_token_mode, selected_section_key,
enabled, ui_collapsed, metadata_write_enabled,
selected_section_keys, machine_id, client_id
FROM plex_settings WHERE id = 1",
[],
|row| {
let base_url: String = row.get(0)?;
let token: String = row.get(1)?;
let manual_token_mode: i32 = row.get(2)?;
let selected_section_key: String = row.get(3)?;
let enabled: i32 = row.get(4)?;
let ui_collapsed: i32 = row.get(5)?;
let metadata_write_enabled: i32 = row.get(6)?;
let selected_section_keys_json: String = row.get(7)?;
let machine_id: String = row.get(8)?;
let client_id: String = row.get(9)?;
let selected_section_keys: Vec<String> =
serde_json::from_str(&selected_section_keys_json).unwrap_or_default();
Ok(PlexSettings {
enabled: enabled != 0,
ui_collapsed: ui_collapsed != 0,
base_url,
token,
manual_token_mode: manual_token_mode != 0,
metadata_write_enabled: metadata_write_enabled != 0,
selected_section_keys,
selected_section_key,
machine_id,
client_id,
})
},
)
.map_err(|e| format!("Failed to get Plex settings: {}", e))
}
/// Persist base URL + token together (the common case after PIN auth or a
/// manual-token save).
pub fn set_credentials(&self, base_url: &str, token: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET base_url = ?1, token = ?2 WHERE id = 1",
params![base_url.trim(), token.trim()],
)
.map_err(|e| format!("Failed to set Plex credentials: {}", e))?;
Ok(())
}
pub fn set_base_url(&self, value: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET base_url = ?1 WHERE id = 1",
params![value.trim()],
)
.map_err(|e| format!("Failed to set Plex base_url: {}", e))?;
Ok(())
}
pub fn set_token(&self, value: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET token = ?1 WHERE id = 1",
params![value.trim()],
)
.map_err(|e| format!("Failed to set Plex token: {}", e))?;
Ok(())
}
pub fn set_manual_token_mode(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET manual_token_mode = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set Plex manual_token_mode: {}", e))?;
Ok(())
}
pub fn set_enabled(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET enabled = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set Plex enabled: {}", e))?;
Ok(())
}
pub fn set_ui_collapsed(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET ui_collapsed = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set Plex ui_collapsed: {}", e))?;
Ok(())
}
pub fn set_metadata_write_enabled(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET metadata_write_enabled = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set Plex metadata_write_enabled: {}", e))?;
Ok(())
}
pub fn set_machine_id(&self, value: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET machine_id = ?1 WHERE id = 1",
params![value.trim()],
)
.map_err(|e| format!("Failed to set Plex machine_id: {}", e))?;
Ok(())
}
/// Persist the selected section keys as a JSON array, AND mirror the first
/// element into the legacy `selected_section_key` column in one UPDATE.
pub fn set_selected_section_keys(&self, keys: &[String]) -> Result<(), String> {
let json = serde_json::to_string(keys)
.map_err(|e| format!("Failed to serialize Plex section keys: {}", e))?;
let legacy = keys.first().cloned().unwrap_or_default();
self.conn
.execute(
"UPDATE plex_settings
SET selected_section_keys = ?1, selected_section_key = ?2
WHERE id = 1",
params![json, legacy],
)
.map_err(|e| format!("Failed to set Plex selected_section_keys: {}", e))?;
Ok(())
}
/// Used internally by the keys setter for back-compat; kept public so a
/// caller can still write the legacy single-key directly if needed.
pub fn set_selected_section_key(&self, value: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings SET selected_section_key = ?1 WHERE id = 1",
params![value.trim()],
)
.map_err(|e| format!("Failed to set Plex selected_section_key: {}", e))?;
Ok(())
}
/// Returns the existing non-empty `client_id`, else generates a stable
/// `qbz-{uuid}`, persists it, and returns it. (`ensurePlexClientId`.)
pub fn get_or_create_client_id(&self) -> Result<String, String> {
let existing: String = self
.conn
.query_row(
"SELECT client_id FROM plex_settings WHERE id = 1",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to read Plex client_id: {}", e))?;
if !existing.is_empty() {
return Ok(existing);
}
let generated = format!("qbz-{}", uuid::Uuid::new_v4());
self.conn
.execute(
"UPDATE plex_settings SET client_id = ?1 WHERE id = 1",
params![generated],
)
.map_err(|e| format!("Failed to persist Plex client_id: {}", e))?;
Ok(generated)
}
/// Reset the connection state (sign out of Plex), keeping `enabled`,
/// `client_id`, and `metadata_write_enabled`. The Plex-cache DB purge
/// (`plex_cache_clear`) is orchestrated by the caller, not the store.
pub fn disconnect(&self) -> Result<(), String> {
self.conn
.execute(
"UPDATE plex_settings
SET base_url = '', token = '', manual_token_mode = 0,
selected_section_keys = '[]', selected_section_key = '',
machine_id = ''
WHERE id = 1",
[],
)
.map_err(|e| format!("Failed to disconnect Plex: {}", e))?;
Ok(())
}
/// Back-compat alias for [`disconnect`]. The single legacy `clear` is no
/// longer one-size; new callers should use `disconnect`.
pub fn clear(&self) -> Result<(), String> {
self.disconnect()
}
}
pub struct PlexSettingsState {
pub store: Arc<Mutex<Option<PlexSettingsStore>>>,
}
impl Default for PlexSettingsState {
fn default() -> Self {
Self::new_empty()
}
}
impl PlexSettingsState {
pub fn new() -> Result<Self, String> {
let store = PlexSettingsStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = PlexSettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock Plex settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock Plex settings store".to_string())?;
*guard = None;
Ok(())
}
fn with_store<T>(
&self,
f: impl FnOnce(&PlexSettingsStore) -> Result<T, String>,
) -> Result<T, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock Plex settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
f(store)
}
pub fn get_settings(&self) -> Result<PlexSettings, String> {
self.with_store(|s| s.get_settings())
}
pub fn set_credentials(&self, base_url: &str, token: &str) -> Result<(), String> {
self.with_store(|s| s.set_credentials(base_url, token))
}
pub fn set_base_url(&self, value: &str) -> Result<(), String> {
self.with_store(|s| s.set_base_url(value))
}
pub fn set_token(&self, value: &str) -> Result<(), String> {
self.with_store(|s| s.set_token(value))
}
pub fn set_manual_token_mode(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_manual_token_mode(value))
}
pub fn set_enabled(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_enabled(value))
}
pub fn set_ui_collapsed(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_ui_collapsed(value))
}
pub fn set_metadata_write_enabled(&self, value: bool) -> Result<(), String> {
self.with_store(|s| s.set_metadata_write_enabled(value))
}
pub fn set_machine_id(&self, value: &str) -> Result<(), String> {
self.with_store(|s| s.set_machine_id(value))
}
pub fn set_selected_section_keys(&self, keys: &[String]) -> Result<(), String> {
self.with_store(|s| s.set_selected_section_keys(keys))
}
pub fn set_selected_section_key(&self, value: &str) -> Result<(), String> {
self.with_store(|s| s.set_selected_section_key(value))
}
pub fn get_or_create_client_id(&self) -> Result<String, String> {
self.with_store(|s| s.get_or_create_client_id())
}
pub fn disconnect(&self) -> Result<(), String> {
self.with_store(|s| s.disconnect())
}
pub fn clear(&self) -> Result<(), String> {
self.with_store(|s| s.clear())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn plex_settings_default_is_unconfigured() {
let settings = PlexSettings::default();
assert!(!settings.enabled);
assert!(!settings.ui_collapsed);
assert!(settings.base_url.is_empty());
assert!(settings.token.is_empty());
assert!(!settings.manual_token_mode);
assert!(!settings.metadata_write_enabled);
assert!(settings.selected_section_keys.is_empty());
assert!(settings.selected_section_key.is_empty());
assert!(settings.machine_id.is_empty());
assert!(settings.client_id.is_empty());
assert!(!settings.is_configured());
}
#[test]
fn plex_settings_store_returns_defaults() {
let dir = unique_test_dir("plex-default");
let store = PlexSettingsStore::new_at(&dir).expect("open store");
let settings = store.get_settings().expect("get settings");
assert!(!settings.is_configured());
assert!(!settings.enabled);
assert!(!settings.ui_collapsed);
assert!(!settings.metadata_write_enabled);
assert!(settings.selected_section_keys.is_empty());
assert!(settings.machine_id.is_empty());
assert!(settings.client_id.is_empty());
assert!(settings.base_url.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plex_settings_persist_all_fields() {
let dir = unique_test_dir("plex-persist");
{
let store = PlexSettingsStore::new_at(&dir).expect("open store");
store
.set_credentials("http://127.0.0.1:32400/", " abc123 ")
.expect("set creds");
store.set_manual_token_mode(true).expect("set manual");
store.set_enabled(true).expect("set enabled");
store.set_ui_collapsed(true).expect("set collapsed");
store
.set_metadata_write_enabled(true)
.expect("set metadata write");
store.set_machine_id(" mid-123 ").expect("set machine id");
store
.set_selected_section_keys(&["5".to_string()])
.expect("set section");
}
let reopened = PlexSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
// set_credentials trims whitespace.
assert_eq!(settings.base_url, "http://127.0.0.1:32400/");
assert_eq!(settings.token, "abc123");
assert!(settings.manual_token_mode);
assert!(settings.enabled);
assert!(settings.ui_collapsed);
assert!(settings.metadata_write_enabled);
assert_eq!(settings.machine_id, "mid-123");
assert_eq!(settings.selected_section_keys, vec!["5".to_string()]);
assert_eq!(settings.selected_section_key, "5");
assert!(settings.is_configured());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plex_selected_section_keys_round_trip_and_legacy_mirror() {
let dir = unique_test_dir("plex-sections");
let store = PlexSettingsStore::new_at(&dir).expect("open store");
store
.set_selected_section_keys(&["3".to_string(), "5".to_string()])
.expect("set keys");
let settings = store.get_settings().expect("get settings");
assert_eq!(
settings.selected_section_keys,
vec!["3".to_string(), "5".to_string()]
);
// Legacy mirror = first element.
assert_eq!(settings.selected_section_key, "3");
// Empty list clears the mirror.
store
.set_selected_section_keys(&[])
.expect("clear keys");
let cleared = store.get_settings().expect("get settings");
assert!(cleared.selected_section_keys.is_empty());
assert!(cleared.selected_section_key.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plex_client_id_is_stable_across_calls_and_reopen() {
let dir = unique_test_dir("plex-clientid");
let first = {
let store = PlexSettingsStore::new_at(&dir).expect("open store");
let a = store.get_or_create_client_id().expect("create id");
let b = store.get_or_create_client_id().expect("reuse id");
assert_eq!(a, b);
assert!(a.starts_with("qbz-"));
a
};
// Across reopen.
let reopened = PlexSettingsStore::new_at(&dir).expect("reopen store");
let again = reopened.get_or_create_client_id().expect("reuse id");
assert_eq!(first, again);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plex_migrates_legacy_four_column_schema_keeping_creds() {
let dir = unique_test_dir("plex-migrate");
std::fs::create_dir_all(&dir).expect("create temp dir");
let db_path = dir.join("plex_settings.db");
{
// Simulate the slice-2a 4-column table with creds present.
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
"CREATE TABLE plex_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
manual_token_mode INTEGER NOT NULL DEFAULT 0,
selected_section_key TEXT NOT NULL DEFAULT ''
);
INSERT INTO plex_settings (id, base_url, token, manual_token_mode, selected_section_key)
VALUES (1, 'http://127.0.0.1:32400', 'legacy-tok', 1, '7');",
)
.expect("create legacy schema");
}
// Confirm only 5 columns before migration.
{
let conn = Connection::open(&db_path).expect("reopen raw");
let cols: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('plex_settings')",
[],
|r| r.get(0),
)
.expect("count cols");
assert_eq!(cols, 5);
}
let store = PlexSettingsStore::new_at(&dir).expect("migrate store");
let settings = store.get_settings().expect("get settings");
// Old creds intact.
assert_eq!(settings.base_url, "http://127.0.0.1:32400");
assert_eq!(settings.token, "legacy-tok");
assert!(settings.manual_token_mode);
assert_eq!(settings.selected_section_key, "7");
// New columns defaulted.
assert!(!settings.enabled);
assert!(!settings.ui_collapsed);
assert!(!settings.metadata_write_enabled);
assert!(settings.selected_section_keys.is_empty());
assert!(settings.machine_id.is_empty());
assert!(settings.client_id.is_empty());
// All 6 new columns added (11 total).
{
let conn = Connection::open(&db_path).expect("reopen raw");
let cols: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('plex_settings')",
[],
|r| r.get(0),
)
.expect("count cols");
assert_eq!(cols, 11);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plex_disconnect_keeps_enabled_client_id_and_metadata_write() {
let dir = unique_test_dir("plex-disconnect");
let store = PlexSettingsStore::new_at(&dir).expect("open store");
store.set_enabled(true).expect("enable");
store.set_metadata_write_enabled(true).expect("meta");
let client_id = store.get_or_create_client_id().expect("client id");
store
.set_credentials("http://host:32400", "tok")
.expect("set creds");
store.set_machine_id("mid").expect("set machine id");
store
.set_selected_section_keys(&["3".to_string(), "4".to_string()])
.expect("set sections");
store.disconnect().expect("disconnect");
let settings = store.get_settings().expect("get after disconnect");
// Creds + sections + machine_id reset.
assert!(settings.base_url.is_empty());
assert!(settings.token.is_empty());
assert!(!settings.manual_token_mode);
assert!(settings.selected_section_keys.is_empty());
assert!(settings.selected_section_key.is_empty());
assert!(settings.machine_id.is_empty());
// Preserved.
assert!(settings.enabled);
assert!(settings.metadata_write_enabled);
assert_eq!(settings.client_id, client_id);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plex_settings_state_requires_init() {
let state = PlexSettingsState::new_empty();
// No active session yet.
assert!(state.get_settings().is_err());
let dir = unique_test_dir("plex-state");
state.init_at(&dir).expect("init at temp dir");
state
.set_credentials("http://127.0.0.1:32400", "xyz")
.expect("set creds via state");
let settings = state.get_settings().expect("get via state");
assert!(settings.is_configured());
assert_eq!(settings.token, "xyz");
state.teardown().expect("teardown");
assert!(state.get_settings().is_err());
let _ = std::fs::remove_dir_all(dir);
}
}
+1
View File
@@ -52,6 +52,7 @@ qconnect-transport-ws = { path = "../qconnect-transport-ws" }
futures-util = { workspace = true }
uuid = { version = "1", features = ["v4"] }
qbz-library = { path = "../qbz-library" }
qbz-plex = { path = "../qbz-plex" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-models = { path = "../qbz-models" }
qbz-qobuz = { path = "../qbz-qobuz" }
+17 -8
View File
@@ -4,8 +4,14 @@
//! This module owns the per-tab navigation and (slice by slice) the data
//! loading for the four browse tabs: Albums / Artists / Folders / Tracks.
//! It reads the shared per-user `library.db` through the already
//! frontend-agnostic `qbz-library` crate (see `library_db::with_db`), and
//! Plex through the `qbz-plex` core crate.
//! frontend-agnostic `qbz-library` crate (see `library_db::with_db`).
//!
//! NOTE (2026-06-07): Plex is NOT yet wired into this view. The Slint Plex
//! port is in progress — spec at
//! `qbz-nix-docs/plex-integration/2026-06-07-plex-slint-build-spec.md`.
//! Until it lands there is no Plex play path, queue builder, creds
//! plumbing, or hydration trigger here, and the Albums tab uses the
//! non-Plex query (see the Albums-tab note below).
//!
//! Folder management, scan, maintenance, and the danger zone do NOT live in
//! this view — they belong under Settings > Local Library. The view's gear
@@ -72,12 +78,15 @@ impl LibTab {
// =============================== Albums tab ===============================
//
// The Albums tab browses the metadata-grouped local albums via the
// server-paginated `get_albums_metadata_page` (the performant path that
// scales past the documented 16K-row freeze). Sort + search are pushed to
// SQL; the grid pages on scroll. The shared `AlbumCollectionView` renders
// the result, covers load from the local filesystem via the source-aware
// artwork pipeline.
// The Albums tab browses the metadata-grouped local albums via
// `get_albums_metadata_grouped` (the non-Plex grouped path). Sort + search
// are pushed to SQL; the grid pages on scroll. The shared
// `AlbumCollectionView` renders the result, covers load from the local
// filesystem via the source-aware artwork pipeline.
//
// TODO (Plex port, slice 3): switch to `get_albums_metadata_page(…,
// Some(plex_cache_path))` so the `plex_aggregated` union surfaces Plex
// albums in the grid. See the build spec referenced in the module header.
/// Generation guard, bumped on every (re)load. A stale in-flight
/// fetch (older search/sort) is discarded on apply, and an in-flight
+109
View File
@@ -58,6 +58,8 @@ mod offline_cache;
mod offline_manager;
mod playlist;
mod playlist_manager;
mod plex_auth;
mod plex_settings;
mod playlist_picker;
mod quality;
mod recently;
@@ -103,6 +105,10 @@ async fn enter_shell(
tray_settings::init_for_user(session.user_id);
let tray = tray_settings::get();
// Bind Plex connection settings to this user (per-user plex_settings.db,
// Slint-only). Seeded into PlexSettingsState lazily on panel open.
plex_settings::init_for_user(session.user_id);
// Create the system tray from this user's persisted settings (gated by
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
// service runs on its own thread; macOS/Windows are no-ops until the
@@ -4505,6 +4511,109 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.on_stop_scan(move || local_library_settings::stop_scan());
}
// Settings > Plex — connection + library selection (LAN-only). The PIN
// poll, ping, library sync, and disconnect/clear-cache all live in
// `plex_auth`; the persisted store is the per-user `plex_settings.db`.
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_load(move || plex_auth::load(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_enable_toggle(move |b| plex_auth::enable_toggle(weak.clone(), handle.clone(), b));
}
{
window
.global::<PlexAuthActions>()
.on_collapse_toggle(move |b| plex_auth::collapse_toggle(b));
}
{
let weak = window.as_weak();
window
.global::<PlexAuthActions>()
.on_set_server_url(move |url| plex_auth::set_server_url(weak.clone(), url.to_string()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_generate_code(move || plex_auth::generate_code(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_open_auth_url(move || plex_auth::open_auth_url(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
window
.global::<PlexAuthActions>()
.on_copy_code(move || plex_auth::copy_code(weak.clone()));
}
{
window
.global::<PlexAuthActions>()
.on_manual_token_toggle(move |b| plex_auth::manual_token_toggle(b));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_set_token(move |tok| plex_auth::set_token(weak.clone(), handle.clone(), tok.to_string()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_ping(move || plex_auth::ping(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_load_sections(move || plex_auth::load_sections(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_toggle_section(move |key| {
plex_auth::toggle_section(weak.clone(), handle.clone(), key.to_string())
});
}
{
window
.global::<PlexAuthActions>()
.on_metadata_write_toggle(move |b| plex_auth::metadata_write_toggle(b));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_disconnect(move || plex_auth::disconnect(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlexAuthActions>()
.on_clear_cache(move || plex_auth::clear_cache(weak.clone(), handle.clone()));
}
// Tag editor (local album metadata) — open via on_media_action("album",
// "edit"); these wire the modal's own actions.
{
+979
View File
@@ -0,0 +1,979 @@
//! Settings > Plex — connection + library-selection controller (Slint).
//!
//! A faithful 1:1 port of the Tauri (`SettingsView.svelte`) Plex auth/settings
//! feature. The persisted store lives in the frontend-agnostic
//! `qbz_app::settings::plex` crate (bound per-user via `crate::plex_settings`),
//! and all Plex HTTP/cache calls go through the `qbz_plex` core crate. This
//! module owns:
//! - the caller-side URL resolver (`resolve_base_url`) + LAN gate
//! (`is_local_address`) — the backend has none,
//! - the 2500 ms PIN poll loop (no timeout / max attempts; abortable),
//! - `machine_id` capture from ping,
//! - `run_auto_setup` (ping -> sections -> sync) and
//! `sync_selected_libraries` (cache_clear -> save_sections -> per-section
//! fetch+save -> reload),
//! - seeding the UI from the store + warm-loading the cache on panel open.
//!
//! Every UI write from a background task hops back via the weak-handle
//! `upgrade_in_event_loop` pattern (see `local_library_settings`). The PIN
//! poll uses a thread-local `slint::Timer` started from the event-loop thread.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use slint::{ComponentHandle, Model, Timer, TimerMode, Weak};
use crate::plex_settings;
use crate::{AppWindow, PlexSectionItem, PlexSettingsState, SettingsState};
// ----------------------------------------------------------------------------
// Status helper — Slint uses inline @tr, so we resolve the (key,args) status
// to a plain English label Rust-side (no 5-file JSON sync). `kind`: 0 none,
// 1 info, 2 connected/ok (highlight green), 3 error (highlight red).
// ----------------------------------------------------------------------------
fn set_status(weak: &Weak<AppWindow>, text: String, kind: i32) {
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_status_text(text.into());
s.set_status_kind(kind);
});
}
// ----------------------------------------------------------------------------
// Caller-side URL helpers (ported verbatim from the Svelte originals).
// ----------------------------------------------------------------------------
/// `normalizePlexServerUrl`: trim + ensure an http(s) scheme.
fn normalize_server_url(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return String::new();
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
}
}
/// Split a normalized URL into (scheme, host-without-port, port-or-None).
/// Minimal parser sufficient for `http(s)://host[:port][/...]`.
fn parse_url(normalized: &str) -> Option<(String, String, Option<String>)> {
let (scheme, rest) = if let Some(r) = normalized.strip_prefix("http://") {
("http", r)
} else if let Some(r) = normalized.strip_prefix("https://") {
("https", r)
} else {
return None;
};
// Authority ends at the first '/', '?' or '#'.
let authority_end = rest
.find(|c| c == '/' || c == '?' || c == '#')
.unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.is_empty() {
return None;
}
// Strip userinfo if present.
let authority = authority.rsplit('@').next().unwrap_or(authority);
let (host, port) = if let Some(idx) = authority.rfind(':') {
// Guard against IPv6 (we don't parse bracketed hosts here; Plex LAN
// addresses are host names or IPv4). If the part after ':' is numeric,
// treat it as a port.
let maybe_port = &authority[idx + 1..];
if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
(authority[..idx].to_string(), Some(maybe_port.to_string()))
} else {
(authority.to_string(), None)
}
} else {
(authority.to_string(), None)
};
if host.is_empty() {
return None;
}
Some((scheme.to_string(), host, port))
}
/// `isPrivateIpv4`.
fn is_private_ipv4(host: &str) -> bool {
let octets: Vec<&str> = host.split('.').collect();
if octets.len() != 4 {
return false;
}
let parsed: Option<Vec<u32>> = octets
.iter()
.map(|o| o.parse::<u32>().ok().filter(|v| *v <= 255))
.collect();
let Some(o) = parsed else {
return false;
};
if o[0] == 10 {
return true;
}
if o[0] == 127 {
return true;
}
if o[0] == 192 && o[1] == 168 {
return true;
}
if o[0] == 172 && (16..=31).contains(&o[1]) {
return true;
}
false
}
/// `isLocalPlexAddress`.
fn is_local_address(url_input: &str) -> bool {
let normalized = normalize_server_url(url_input);
if normalized.is_empty() {
return false;
}
let Some((_, host, _)) = parse_url(&normalized) else {
return false;
};
let host = host.to_ascii_lowercase();
if host == "localhost" || host == "::1" {
return true;
}
if host.ends_with(".local") || host.ends_with(".lan") {
return true;
}
if !host.contains('.') {
return true;
}
is_private_ipv4(&host)
}
/// `resolvePlexBaseUrl`: normalize, validate scheme, default port 32400,
/// return `proto://host:port`. Empty on failure.
fn resolve_base_url(server_url: &str) -> String {
let normalized = normalize_server_url(server_url);
if normalized.is_empty() {
return String::new();
}
let Some((scheme, host, port)) = parse_url(&normalized) else {
return String::new();
};
if scheme != "http" && scheme != "https" {
return String::new();
}
let port = port.unwrap_or_else(|| "32400".to_string());
format!("{scheme}://{host}:{port}")
}
/// Host-form (`proto://host`) for seeding the input from a stored base_url.
fn host_form_from_base(base_url: &str) -> String {
let src = if base_url.is_empty() {
"http://127.0.0.1:32400"
} else {
base_url
};
match parse_url(src) {
Some((scheme, host, _)) => format!("{scheme}://{host}"),
None => "http://127.0.0.1".to_string(),
}
}
/// `canUsePlexRequests`: enabled && local && base!="" && token!="".
fn can_use(enabled: bool, server_url: &str, token: &str) -> bool {
enabled
&& is_local_address(server_url)
&& !resolve_base_url(server_url).is_empty()
&& !token.trim().is_empty()
}
// ----------------------------------------------------------------------------
// Persist + resolve the connection config (mirrors `persistPlexConfig`): the
// resolved base_url + the token. Returns the resolved base_url.
// ----------------------------------------------------------------------------
fn persist_config(server_url: &str, token: &str) -> String {
let base = resolve_base_url(server_url);
plex_settings::set_credentials(&base, token.trim());
base
}
/// Recompute + push the derived `is-local-address`, `base-url`, `can-use`
/// gates onto the UI from the current state. Runs on the event loop.
fn refresh_gates(w: &AppWindow) {
let s = w.global::<PlexSettingsState>();
let server_url = s.get_server_url().to_string();
let enabled = s.get_enabled();
let token = s.get_token().to_string();
let local = is_local_address(&server_url);
let base = resolve_base_url(&server_url);
s.set_is_local_address(local);
s.set_base_url(base.clone().into());
s.set_can_use(enabled && local && !base.is_empty() && !token.trim().is_empty());
}
// ----------------------------------------------------------------------------
// PIN poll lifecycle. The Timer must be started from the event-loop thread.
// ----------------------------------------------------------------------------
thread_local! {
/// One reusable poll timer (event-loop thread). Lifecycle: started by
/// `generate_code`, stopped on authorized / expired / disconnect.
static PIN_POLL: Timer = Timer::default();
}
/// Bumped whenever a poll is (re)started so a stale in-flight async check
/// landing after a newer start/stop is ignored.
static PIN_GEN: AtomicU64 = AtomicU64::new(0);
/// Set once the first background auto-setup has run this session. Re-entering
/// the Local Library settings panel must NOT re-fire the heavy
/// `cache_clear` + full refetch on every section switch (the Svelte original
/// refreshes once per Settings open, not once per section). Reset by
/// `enable_toggle(true)` so re-enabling Plex forces a fresh refresh.
static BG_REFRESH_DONE: AtomicBool = AtomicBool::new(false);
fn stop_pin_poll() {
PIN_GEN.fetch_add(1, Ordering::SeqCst);
PIN_POLL.with(|t| t.stop());
}
// ----------------------------------------------------------------------------
// Callback handlers (bound in main.rs).
// ----------------------------------------------------------------------------
/// Panel init: seed the UI from the store, then (if enabled + usable) warm the
/// cached sections/tracks and kick a background refresh. Mirrors the Svelte
/// `loadPlexCachedState` + `refreshPlexInBackground`.
pub fn load(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let cfg = plex_settings::get();
let server_host = host_form_from_base(&cfg.base_url);
let selected = cfg.selected_section_keys.clone();
// Values needed after `cfg` is moved into the seeding closure.
let enabled = cfg.enabled;
let token = cfg.token.clone();
let server_url = host_form_from_base(&cfg.base_url);
let weak2 = weak.clone();
let server_host_for_ui = server_host.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_enabled(cfg.enabled);
s.set_ui_collapsed(cfg.ui_collapsed);
s.set_metadata_write_enabled(cfg.metadata_write_enabled);
s.set_server_url(server_host_for_ui.into());
s.set_base_url(cfg.base_url.clone().into());
s.set_token(cfg.token.clone().into());
s.set_manual_token_mode(cfg.manual_token_mode);
s.set_token_input(cfg.token.clone().into());
// Reset transient PIN/status on (re)mount.
s.set_pin_busy(false);
s.set_pin_code("".into());
s.set_pin_auth_url("".into());
s.set_busy(false);
refresh_gates(&w);
});
if !enabled {
return;
}
// Warm cached sections + tracks off the event loop.
let handle_spawn = handle.clone();
handle_spawn.spawn(async move {
let sections = tokio::task::spawn_blocking(qbz_plex::plex_cache_get_sections)
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
let counts = section_counts(&sections);
let items = build_section_items(&sections, &selected, &counts);
let cached_tracks = tokio::task::spawn_blocking(|| qbz_plex::plex_cache_get_tracks(None, Some(100_000)))
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
let track_count = cached_tracks.len();
let weak3 = weak2.clone();
let _ = weak2.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
if !items.is_empty() {
s.set_sections(items_model(items));
}
});
if track_count > 0 {
set_status(
&weak3,
format!("Loaded {track_count} cached tracks"),
1,
);
}
// Background refresh if currently usable — ONCE per session (not on
// every panel mount; the panel is conditionally mounted per settings
// section, so an unguarded refresh would cache_clear+refetch on every
// entry into Settings > Local Library).
if can_use(enabled, &server_url, &token)
&& !BG_REFRESH_DONE.swap(true, Ordering::SeqCst)
{
run_auto_setup(weak3, handle, server_url, token).await;
}
});
}
pub fn enable_toggle(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, enabled: bool) {
plex_settings::set_enabled(enabled);
let weak2 = weak.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<PlexSettingsState>().set_enabled(enabled);
refresh_gates(&w);
});
if enabled {
set_status(&weak2, "Idle".to_string(), 1);
// Re-enabling forces one fresh background refresh.
BG_REFRESH_DONE.store(false, Ordering::SeqCst);
// Warm + refresh like the panel init.
load(weak2, handle);
} else {
stop_pin_poll();
set_status(&weak2, "Disabled".to_string(), 0);
}
}
pub fn collapse_toggle(collapsed: bool) {
plex_settings::set_ui_collapsed(collapsed);
}
pub fn metadata_write_toggle(enabled: bool) {
plex_settings::set_metadata_write_enabled(enabled);
}
/// Server-address field accepted: resolve + persist the base_url, refresh gates.
pub fn set_server_url(weak: Weak<AppWindow>, server_url: String) {
let base = resolve_base_url(&server_url);
plex_settings::set_base_url(&base);
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_server_url(server_url.into());
s.set_base_url(base.into());
refresh_gates(&w);
});
}
/// Manual-token toggle: persist mode. Hides/show affordances via the bound flag.
pub fn manual_token_toggle(enabled: bool) {
plex_settings::set_manual_token_mode(enabled);
}
/// Save a manually-entered token, then auto-setup if usable. Mirrors
/// `handlePlexTokenBlur` + manual mode persist.
pub fn set_token(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, token: String) {
plex_settings::set_manual_token_mode(true);
let server_url = read_server_url(&weak);
let base = persist_config(&server_url, &token);
plex_settings::set_token(token.trim());
let token_clean = token.trim().to_string();
let weak2 = weak.clone();
let _ = weak.upgrade_in_event_loop({
let token_clean = token_clean.clone();
move |w| {
let s = w.global::<PlexSettingsState>();
s.set_token(token_clean.into());
s.set_manual_token_mode(true);
refresh_gates(&w);
}
});
let enabled = plex_settings::get().enabled;
if can_use(enabled, &server_url, &token_clean) {
let _ = base;
let handle2 = handle.clone();
handle.spawn(async move {
run_auto_setup(weak2, handle2, server_url, token_clean).await;
});
}
}
/// Read the current server-url field synchronously (best-effort).
fn read_server_url(weak: &Weak<AppWindow>) -> String {
weak.upgrade()
.map(|w| w.global::<PlexSettingsState>().get_server_url().to_string())
.unwrap_or_default()
}
/// `handlePlexConnectEasy`: PIN start + begin the 2500 ms poll.
pub fn generate_code(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let cfg = plex_settings::get();
let server_url = read_server_url(&weak);
if !cfg.enabled || !is_local_address(&server_url) || resolve_base_url(&server_url).is_empty() {
return;
}
// Persist config (base_url) up front, like the Svelte original.
let token_now = cfg.token.clone();
let _ = persist_config(&server_url, &token_now);
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<PlexSettingsState>();
s.set_pin_busy(true);
});
let client_id = plex_settings::get_or_create_client_id();
let weak2 = weak.clone();
let handle2 = handle.clone();
// Capture the resolved server_url NOW (event-loop thread) and thread it
// through the poll — the authorized branch runs on a tokio thread where
// `weak.upgrade()` is None, so it must not re-read the field.
let server_url_poll = server_url.clone();
handle.spawn(async move {
match qbz_plex::plex_auth_pin_start(client_id.clone()).await {
Ok(pin) => {
let pin_id = pin.pin_id;
let code = pin.code.clone();
let auth_url = pin.auth_url.clone();
let code_for_status = code.clone();
let _ = weak2.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_pin_busy(false);
s.set_pin_code(code.into());
s.set_pin_auth_url(auth_url.into());
});
set_status(
&weak2,
format!("Enter code {code_for_status} at the Plex sign-in page"),
1,
);
// Start the repeating poll on the event-loop thread.
let weak_timer = weak2.clone();
let handle_timer = handle2.clone();
let _ = weak2.upgrade_in_event_loop(move |_w| {
begin_pin_poll(
weak_timer,
handle_timer,
client_id,
pin_id,
code_for_status,
server_url_poll,
);
});
}
Err(e) => {
let _ = weak2.upgrade_in_event_loop(|w| {
w.global::<PlexSettingsState>().set_pin_busy(false);
});
set_status(&weak2, format!("Error: {e}"), 3);
crate::toast::error_weak(&weak2, "Plex link failed to start");
}
}
});
}
/// Begin the 2500 ms repeating poll. MUST be called from the event-loop thread.
fn begin_pin_poll(
weak: Weak<AppWindow>,
handle: tokio::runtime::Handle,
client_id: String,
pin_id: u64,
code: String,
server_url: String,
) {
let my_gen = PIN_GEN.fetch_add(1, Ordering::SeqCst) + 1;
PIN_POLL.with(|t| {
t.stop();
let weak = weak.clone();
t.start(TimerMode::Repeated, Duration::from_millis(2500), move || {
// Drop this tick if a newer poll superseded us.
if PIN_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
// Self-terminate if the user navigated away from Settings > Local
// Library (section 4). The panel is conditionally mounted and Slint
// has no unmount hook, so the poll stops itself instead of leaking a
// 2500ms background hit against plex.tv. This tick runs on the
// event-loop thread, so `upgrade()` is valid here.
let on_panel = weak
.upgrade()
.map(|w| w.global::<SettingsState>().get_section() == 4)
.unwrap_or(false);
if !on_panel {
stop_pin_poll();
return;
}
let weak = weak.clone();
let handle = handle.clone();
let client_id = client_id.clone();
let code = code.clone();
let server_url = server_url.clone();
handle.clone().spawn(async move {
if PIN_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
match qbz_plex::plex_auth_pin_check(client_id.clone(), pin_id, Some(code.clone()))
.await
{
Ok(check) => {
if check.authorized {
if let Some(token) = check.auth_token {
// Stop ticking immediately.
let _ = weak.upgrade_in_event_loop(|_w| stop_pin_poll());
// `server_url` was captured on the event loop at
// code-generation time; do NOT re-read it here
// (tokio thread → upgrade() None → blank base_url).
let _ = persist_config(&server_url, &token);
plex_settings::set_token(token.trim());
plex_settings::set_manual_token_mode(false);
let token_ui = token.trim().to_string();
let _ = weak.upgrade_in_event_loop({
let token_ui = token_ui.clone();
move |w| {
let s = w.global::<PlexSettingsState>();
s.set_token(token_ui.into());
s.set_manual_token_mode(false);
s.set_pin_code("".into());
s.set_pin_auth_url("".into());
refresh_gates(&w);
}
});
set_status(&weak, "Connected to Plex".to_string(), 2);
run_auto_setup(weak.clone(), handle.clone(), server_url, token_ui)
.await;
}
} else if check.expired {
let _ = weak.upgrade_in_event_loop(|w| {
stop_pin_poll();
let s = w.global::<PlexSettingsState>();
s.set_pin_code("".into());
s.set_pin_auth_url("".into());
});
set_status(&weak, "Link code expired".to_string(), 3);
}
// else: still pending — keep polling.
}
Err(e) => {
// Transient: swallow + keep polling (matches Svelte).
log::warn!("[qbz-slint] Plex auth poll error: {e}");
}
}
});
});
});
}
pub fn open_auth_url(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let url = weak
.upgrade()
.map(|w| w.global::<PlexSettingsState>().get_pin_auth_url().to_string())
.unwrap_or_default();
if url.is_empty() {
return;
}
handle.spawn(async move {
if let Err(e) = qbz_plex::plex_open_auth_url(url).await {
log::warn!("[qbz-slint] open Plex auth url failed: {e}");
}
});
}
pub fn copy_code(weak: Weak<AppWindow>) {
let code = weak
.upgrade()
.map(|w| w.global::<PlexSettingsState>().get_pin_code().to_string())
.unwrap_or_default();
if code.is_empty() {
return;
}
crate::share::copy_to_clipboard(code);
crate::toast::success_weak(&weak, "Code copied");
}
/// `handlePlexPing`: capture machine_id + set connected status. Returns whether
/// the ping succeeded (used by auto-setup).
async fn ping_inner(
weak: Weak<AppWindow>,
base_url: String,
token: String,
) -> bool {
let _ = weak.upgrade_in_event_loop(|w| w.global::<PlexSettingsState>().set_busy(true));
let result = qbz_plex::plex_ping(base_url.trim().to_string(), token.trim().to_string()).await;
let ok = match result {
Ok(info) => {
let machine = info.machine_identifier.clone().unwrap_or_default();
if !machine.is_empty() {
plex_settings::set_machine_id(&machine);
}
let server = info
.friendly_name
.clone()
.or(info.machine_identifier.clone())
.unwrap_or_else(|| "Plex".to_string());
let version = info.version.clone().unwrap_or_else(|| "?".to_string());
set_status(&weak, format!("Connected to {server} (v{version})"), 2);
true
}
Err(e) => {
set_status(&weak, format!("Error: {e}"), 3);
false
}
};
let _ = weak.upgrade_in_event_loop(|w| w.global::<PlexSettingsState>().set_busy(false));
ok
}
pub fn ping(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let cfg = plex_settings::get();
let server_url = read_server_url(&weak);
if !can_use(cfg.enabled, &server_url, &cfg.token) {
return;
}
let base = persist_config(&server_url, &cfg.token);
let token = cfg.token.clone();
handle.spawn(async move {
ping_inner(weak, base, token).await;
});
}
/// Build the section UI items from sections + selection + counts.
fn build_section_items(
sections: &[qbz_plex::PlexMusicSection],
selected: &[String],
counts: &std::collections::HashMap<String, i32>,
) -> Vec<PlexSectionItem> {
sections
.iter()
.map(|s| PlexSectionItem {
key: s.key.clone().into(),
title: s.title.clone().into(),
count: counts.get(&s.key).copied().unwrap_or(0),
selected: selected.iter().any(|k| k == &s.key),
})
.collect()
}
fn items_model(items: Vec<PlexSectionItem>) -> slint::ModelRc<PlexSectionItem> {
slint::ModelRc::new(slint::VecModel::from(items))
}
/// Per-section cached track counts (blocking cache reads).
fn section_counts(
sections: &[qbz_plex::PlexMusicSection],
) -> std::collections::HashMap<String, i32> {
let mut counts = std::collections::HashMap::new();
for s in sections {
let n = qbz_plex::plex_cache_get_tracks(Some(s.key.clone()), Some(100_000))
.map(|t| t.len() as i32)
.unwrap_or(0);
counts.insert(s.key.clone(), n);
}
counts
}
/// `handlePlexLoadSections` (autoSyncSelected: true) — fetch sections, save,
/// default-select-ALL when none persisted, then sync.
async fn load_sections_inner(
weak: Weak<AppWindow>,
handle: tokio::runtime::Handle,
base_url: String,
token: String,
) {
let _ = weak.upgrade_in_event_loop(|w| w.global::<PlexSettingsState>().set_busy(true));
let sections =
match qbz_plex::plex_get_music_sections(base_url.trim().to_string(), token.trim().to_string())
.await
{
Ok(s) => s,
Err(e) => {
set_status(&weak, format!("Error: {e}"), 3);
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<PlexSettingsState>().set_busy(false)
});
return;
}
};
let machine_id = plex_settings::get().machine_id;
let server_id = (!machine_id.is_empty()).then_some(machine_id.clone());
let sections_for_save = sections.clone();
let _ = tokio::task::spawn_blocking(move || {
qbz_plex::plex_cache_save_sections(server_id, sections_for_save)
})
.await;
// Default-select ALL when persisted selection is empty / stale.
let available: std::collections::HashSet<String> =
sections.iter().map(|s| s.key.clone()).collect();
let persisted: Vec<String> = plex_settings::get()
.selected_section_keys
.into_iter()
.filter(|k| available.contains(k))
.collect();
let selected: Vec<String> = if persisted.is_empty() {
sections.iter().map(|s| s.key.clone()).collect()
} else {
persisted
};
plex_settings::set_selected_section_keys(&selected);
let count = sections.len();
set_status(&weak, format!("{count} libraries found"), 1);
// Push the section model (counts come from the sync reload below).
let counts = {
let sections = sections.clone();
tokio::task::spawn_blocking(move || section_counts(&sections))
.await
.unwrap_or_default()
};
let items = build_section_items(&sections, &selected, &counts);
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_sections(items_model(items));
s.set_busy(false);
});
sync_selected_libraries(weak, handle, base_url, token, sections).await;
}
pub fn load_sections(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let cfg = plex_settings::get();
let server_url = read_server_url(&weak);
if !can_use(cfg.enabled, &server_url, &cfg.token) {
return;
}
let base = persist_config(&server_url, &cfg.token);
let token = cfg.token.clone();
let handle2 = handle.clone();
handle.spawn(async move {
load_sections_inner(weak, handle2, base, token).await;
});
}
/// `syncSelectedPlexLibraries`: cache_clear -> save_sections -> per selected
/// section get_section_tracks+save_tracks -> reload get_tracks(None).
async fn sync_selected_libraries(
weak: Weak<AppWindow>,
_handle: tokio::runtime::Handle,
base_url: String,
token: String,
sections: Vec<qbz_plex::PlexMusicSection>,
) {
let _ = weak.upgrade_in_event_loop(|w| w.global::<PlexSettingsState>().set_busy(true));
let machine_id = plex_settings::get().machine_id;
let server_id = (!machine_id.is_empty()).then_some(machine_id.clone());
let selected = plex_settings::get().selected_section_keys;
// cache_clear + re-save sections (blocking).
{
let sections = sections.clone();
let server_id = server_id.clone();
let _ = tokio::task::spawn_blocking(move || {
let _ = qbz_plex::plex_cache_clear();
if !sections.is_empty() {
let _ = qbz_plex::plex_cache_save_sections(server_id, sections);
}
})
.await;
}
if selected.is_empty() {
// Nothing selected → clear the track view, status 0 tracks.
let items = build_section_items(&sections, &selected, &std::collections::HashMap::new());
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_sections(items_model(items));
s.set_busy(false);
});
set_status(&weak, "0 tracks loaded".to_string(), 1);
return;
}
let mut total = 0usize;
let mut counts: std::collections::HashMap<String, i32> = std::collections::HashMap::new();
for key in &selected {
let tracks = match qbz_plex::plex_get_section_tracks(
base_url.trim().to_string(),
token.trim().to_string(),
key.clone(),
None,
)
.await
{
Ok(t) => t,
Err(e) => {
log::warn!("[qbz-slint] Plex get_section_tracks({key}) failed: {e}");
continue;
}
};
let n = tracks.len();
counts.insert(key.clone(), n as i32);
total += n;
let server_id = server_id.clone();
let key2 = key.clone();
let _ = tokio::task::spawn_blocking(move || {
qbz_plex::plex_cache_save_tracks(server_id, key2, tracks)
})
.await;
}
// Reload all so downstream browse views see fresh cache.
let _ = tokio::task::spawn_blocking(|| qbz_plex::plex_cache_get_tracks(None, Some(100_000))).await;
let items = build_section_items(&sections, &selected, &counts);
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
s.set_sections(items_model(items));
s.set_busy(false);
});
set_status(&weak, format!("{total} tracks loaded"), 2);
}
/// `runPlexAutoSetup`: ping → (on success) sections → sync.
async fn run_auto_setup(
weak: Weak<AppWindow>,
handle: tokio::runtime::Handle,
server_url: String,
token: String,
) {
let base = resolve_base_url(&server_url);
if base.is_empty() {
return;
}
if !ping_inner(weak.clone(), base.clone(), token.clone()).await {
return;
}
load_sections_inner(weak, handle, base, token).await;
}
/// `handlePlexLibraryToggle`: flip membership, persist (+legacy mirror), then
/// immediate full re-sync.
pub fn toggle_section(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, key: String) {
let cfg = plex_settings::get();
// Build the new selection ordered by the current section list.
let server_url = read_server_url(&weak);
let current_sections = read_sections(&weak);
let mut set: std::collections::HashSet<String> =
cfg.selected_section_keys.iter().cloned().collect();
if set.contains(&key) {
set.remove(&key);
} else {
set.insert(key.clone());
}
let ordered: Vec<String> = current_sections
.iter()
.map(|s| s.key.to_string())
.filter(|k| set.contains(k))
.collect();
plex_settings::set_selected_section_keys(&ordered);
// Reflect selection immediately in the UI.
let ordered_for_ui = ordered.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<PlexSettingsState>();
let model = s.get_sections();
let updated: Vec<PlexSectionItem> = (0..model.row_count())
.filter_map(|i| slint::Model::row_data(&model, i))
.map(|mut it| {
it.selected = ordered_for_ui.iter().any(|k| k == &it.key.to_string());
it
})
.collect();
s.set_sections(items_model(updated));
});
if !can_use(cfg.enabled, &server_url, &cfg.token) {
return;
}
let base = resolve_base_url(&server_url);
let token = cfg.token.clone();
let sections: Vec<qbz_plex::PlexMusicSection> = current_sections
.iter()
.map(|s| qbz_plex::PlexMusicSection {
key: s.key.to_string(),
title: s.title.to_string(),
})
.collect();
let handle2 = handle.clone();
handle.spawn(async move {
sync_selected_libraries(weak, handle2, base, token, sections).await;
});
}
/// Snapshot the current section model from the UI (best-effort).
fn read_sections(weak: &Weak<AppWindow>) -> Vec<PlexSectionItem> {
weak.upgrade()
.map(|w| {
let model = w.global::<PlexSettingsState>().get_sections();
(0..model.row_count())
.filter_map(|i| slint::Model::row_data(&model, i))
.collect()
})
.unwrap_or_default()
}
/// `handlePlexDisconnect`: confirm → reset creds/sections/machine_id + clear
/// cache. Keeps enabled/client_id/metadata_write.
pub fn disconnect(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
handle.spawn(async move {
let ok = rfd::AsyncMessageDialog::new()
.set_title("Disconnect from Plex?")
.set_description(
"This signs out of Plex and clears the locally cached libraries and tracks.",
)
.set_buttons(rfd::MessageButtons::YesNo)
.show()
.await;
if ok != rfd::MessageDialogResult::Yes {
return;
}
// Stop any active poll on the event loop.
let _ = weak.upgrade_in_event_loop(|_w| stop_pin_poll());
plex_settings::disconnect();
let _ = tokio::task::spawn_blocking(qbz_plex::plex_cache_clear).await;
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<PlexSettingsState>();
s.set_token("".into());
s.set_token_input("".into());
s.set_manual_token_mode(false);
s.set_pin_code("".into());
s.set_pin_auth_url("".into());
s.set_pin_busy(false);
s.set_busy(false);
s.set_sections(items_model(Vec::new()));
refresh_gates(&w);
});
set_status(&weak, "Disconnected".to_string(), 1);
});
}
/// `handlePlexClearCache`: confirm → plex_cache_clear only (token kept).
pub fn clear_cache(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
handle.spawn(async move {
let ok = rfd::AsyncMessageDialog::new()
.set_title("Clear Plex cache?")
.set_description("This removes cached Plex libraries and tracks. Your sign-in is kept.")
.set_buttons(rfd::MessageButtons::YesNo)
.show()
.await;
if ok != rfd::MessageDialogResult::Yes {
return;
}
let cleared = tokio::task::spawn_blocking(qbz_plex::plex_cache_clear)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
let _ = weak.upgrade_in_event_loop(|w| {
// Zero out the cached counts (sections kept; tracks gone).
let s = w.global::<PlexSettingsState>();
let model = s.get_sections();
let cleared_items: Vec<PlexSectionItem> = (0..model.row_count())
.filter_map(|i| slint::Model::row_data(&model, i))
.map(|mut it| {
it.count = 0;
it
})
.collect();
s.set_sections(items_model(cleared_items));
});
if cleared {
set_status(&weak, "Cache cleared".to_string(), 1);
} else {
crate::toast::error_weak(&weak, "Couldn't clear the Plex cache");
}
});
}
+122
View File
@@ -0,0 +1,122 @@
//! Slint-side glue for Plex connection settings.
//!
//! The persisted model + SQLite store live in the shared, frontend-agnostic
//! `qbz_app::settings::plex` module (ADR-006). This module only owns a
//! process-global handle to that store, bound to the active user the same way
//! `tray_settings` binds the per-user `tray_settings.db`:
//!
//! <data_dir>/qbz/users/<user_id>/plex_settings.db
//!
//! so credentials are scoped per Qobuz user. Runtime concerns — PIN auth,
//! ping, browse, library sync — live in `plex_auth` (the controller) and the
//! `qbz-plex` core crate.
use std::path::PathBuf;
use std::sync::LazyLock;
pub use qbz_app::settings::plex::{PlexSettings, PlexSettingsState};
/// Process-global Plex settings handle. Starts session-less (`new_empty`);
/// `init_for_user` binds it to the per-user DB on shell entry.
pub static STATE: LazyLock<PlexSettingsState> = LazyLock::new(PlexSettingsState::new_empty);
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory the shared
/// store appends `plex_settings.db` to. Matches the tray per-user path.
fn user_dir(user_id: u64) -> Option<PathBuf> {
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string()),
)
}
/// Bind the store to `user_id` on shell entry. Best-effort — failures are
/// logged and leave the store session-less (settings degrade to defaults).
pub fn init_for_user(user_id: u64) {
let Some(dir) = user_dir(user_id) else {
log::warn!("[qbz-slint] plex settings: data dir unavailable, not initialized");
return;
};
if let Err(e) = STATE.init_at(&dir) {
log::error!("[qbz-slint] plex settings init failed: {e}");
}
}
/// Current persisted settings, or `PlexSettings::default()` when there is no
/// active session / the read fails.
pub fn get() -> PlexSettings {
STATE.get_settings().unwrap_or_default()
}
pub fn set_enabled(value: bool) {
if let Err(e) = STATE.set_enabled(value) {
log::error!("[qbz-slint] plex settings set_enabled failed: {e}");
}
}
pub fn set_ui_collapsed(value: bool) {
if let Err(e) = STATE.set_ui_collapsed(value) {
log::error!("[qbz-slint] plex settings set_ui_collapsed failed: {e}");
}
}
pub fn set_metadata_write_enabled(value: bool) {
if let Err(e) = STATE.set_metadata_write_enabled(value) {
log::error!("[qbz-slint] plex settings set_metadata_write_enabled failed: {e}");
}
}
pub fn set_base_url(value: &str) {
if let Err(e) = STATE.set_base_url(value) {
log::error!("[qbz-slint] plex settings set_base_url failed: {e}");
}
}
pub fn set_credentials(base_url: &str, token: &str) {
if let Err(e) = STATE.set_credentials(base_url, token) {
log::error!("[qbz-slint] plex settings set_credentials failed: {e}");
}
}
pub fn set_token(value: &str) {
if let Err(e) = STATE.set_token(value) {
log::error!("[qbz-slint] plex settings set_token failed: {e}");
}
}
pub fn set_manual_token_mode(value: bool) {
if let Err(e) = STATE.set_manual_token_mode(value) {
log::error!("[qbz-slint] plex settings set_manual_token_mode failed: {e}");
}
}
pub fn set_machine_id(value: &str) {
if let Err(e) = STATE.set_machine_id(value) {
log::error!("[qbz-slint] plex settings set_machine_id failed: {e}");
}
}
pub fn set_selected_section_keys(keys: &[String]) {
if let Err(e) = STATE.set_selected_section_keys(keys) {
log::error!("[qbz-slint] plex settings set_selected_section_keys failed: {e}");
}
}
/// Returns the stable per-user `qbz-{uuid}` client id, generating + persisting
/// it on first use. Empty string on failure (logged).
pub fn get_or_create_client_id() -> String {
match STATE.get_or_create_client_id() {
Ok(id) => id,
Err(e) => {
log::error!("[qbz-slint] plex settings get_or_create_client_id failed: {e}");
String::new()
}
}
}
pub fn disconnect() {
if let Err(e) = STATE.disconnect() {
log::error!("[qbz-slint] plex settings disconnect failed: {e}");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -9,12 +9,13 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { LibraryFoldersState, LibraryManageActions, LibraryScanState } from "../state.slint";
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions } from "../state.slint";
import { SettingRow } from "SettingRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { ScrollView } from "std-widgets.slint";
import { ScrollView, LineEdit } from "std-widgets.slint";
component GroupHeader inherits Text {
color: Theme.text-muted;
@@ -162,9 +163,11 @@ export component LocalLibrarySettings inherits VerticalLayout {
spacing: 4px;
// Lazy-load the folder list each time the panel is shown (the `if
// section==4` mount in SettingsView creates this fresh on entry).
// section==4` mount in SettingsView creates this fresh on entry). The
// Plex panel below self-loads here too (seed from store + warm cache).
init => {
LibraryManageActions.load();
PlexAuthActions.load();
}
// --- Library folders -------------------------------------------------
@@ -352,4 +355,337 @@ export component LocalLibrarySettings inherits VerticalLayout {
clicked => { LibraryManageActions.clear-library(); }
}
}
Rectangle { height: 28px; }
// ===================================================================
// PLEX — LAN-only Plex Media Server connection (Settings > Plex).
// Body renders only when `enabled && !ui-collapsed`. ADR-008: no pills.
// ===================================================================
// Header row: title + master toggle + collapse chevron.
HorizontalLayout {
spacing: 10px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
HorizontalLayout {
spacing: 8px;
GroupHeader { text: @tr("PLEX"); vertical-alignment: center; }
// Collapse chevron (only meaningful when enabled).
if PlexSettingsState.enabled: VerticalLayout {
alignment: center;
Rectangle {
width: 20px;
height: 20px;
border-radius: Radius.sm;
background: chev-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: PlexSettingsState.ui-collapsed
? @image-url("../assets/icons/chevron-right.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-secondary;
}
chev-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
PlexSettingsState.ui-collapsed = !PlexSettingsState.ui-collapsed;
PlexAuthActions.collapse-toggle(PlexSettingsState.ui-collapsed);
}
}
}
}
}
Text {
text: @tr("Connect a Plex Media Server on your local network.");
color: Theme.text-muted;
font-size: 12px;
wrap: word-wrap;
}
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: PlexSettingsState.enabled;
toggled(v) => {
PlexSettingsState.enabled = v;
PlexAuthActions.enable-toggle(v);
}
}
}
}
// Body — only when enabled and not collapsed.
if PlexSettingsState.enabled && !PlexSettingsState.ui-collapsed: VerticalLayout {
spacing: 4px;
Rectangle { height: 10px; }
// --- Server address ------------------------------------------------
SettingRow {
label: @tr("Server address");
description: @tr("Only local network servers are supported.");
HorizontalLayout {
width: 240px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
LineEdit {
text: PlexSettingsState.server-url;
placeholder-text: @tr("http://127.0.0.1:32400");
edited(s) => {
PlexSettingsState.server-url = s;
}
accepted(s) => {
PlexAuthActions.set-server-url(s);
}
}
}
}
}
// LAN-only inline warning (read-only indicator).
if !PlexSettingsState.is-local-address && PlexSettingsState.server-url != "": Text {
text: @tr("Only local network servers are supported.");
color: #e0564f;
font-size: Typography.legal;
wrap: word-wrap;
}
Rectangle { height: 8px; }
// --- Authorize (PIN) ----------------------------------------------
// Hidden in manual-token mode.
if !PlexSettingsState.manual-token-mode: SettingRow {
label: @tr("Authorize");
description: @tr("Generate a code and sign in to Plex in your browser.");
VerticalLayout {
alignment: center;
SecondaryButton {
label: PlexSettingsState.pin-busy ? @tr("Working...") : @tr("Generate code");
enabled: PlexSettingsState.enabled
&& PlexSettingsState.is-local-address
&& !PlexSettingsState.pin-busy;
clicked => { PlexAuthActions.generate-code(); }
}
}
}
// --- Code block (only while a code exists) ------------------------
if PlexSettingsState.pin-code != "": SettingRow {
label: @tr("Link code");
description: @tr("Enter this code at the Plex sign-in page.");
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
Rectangle {
width: code-text.preferred-width + 20px;
height: 34px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
code-text := Text {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
text: PlexSettingsState.pin-code;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
}
}
SecondaryButton {
label: @tr("Copy code");
clicked => { PlexAuthActions.copy-code(); }
}
SecondaryButton {
label: @tr("Open Plex sign-in");
clicked => { PlexAuthActions.open-auth-url(); }
}
}
}
Rectangle { height: 8px; }
// --- Manual token toggle + field ----------------------------------
SettingRow {
label: @tr("Enter token manually");
description: @tr("Use an existing X-Plex-Token instead of the PIN flow.");
VerticalLayout {
alignment: center;
QbzToggle {
checked: PlexSettingsState.manual-token-mode;
toggled(v) => {
PlexSettingsState.manual-token-mode = v;
PlexAuthActions.manual-token-toggle(v);
}
}
}
}
if PlexSettingsState.manual-token-mode: SettingRow {
label: @tr("Token");
HorizontalLayout {
width: 240px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
LineEdit {
text: PlexSettingsState.token-input;
placeholder-text: @tr("X-Plex-Token");
input-type: password;
edited(s) => {
PlexSettingsState.token-input = s;
}
accepted(s) => {
PlexAuthActions.set-token(s);
}
}
}
}
}
Rectangle { height: 14px; }
// --- Connection actions -------------------------------------------
SettingRow {
label: @tr("Check connection");
description: @tr("Verify the server is reachable.");
VerticalLayout {
alignment: center;
SecondaryButton {
label: @tr("Check connection");
enabled: PlexSettingsState.can-use && !PlexSettingsState.busy;
clicked => { PlexAuthActions.ping(); }
}
}
}
SettingRow {
label: @tr("Libraries");
description: @tr("Fetch your Plex music libraries.");
VerticalLayout {
alignment: center;
SecondaryButton {
label: @tr("Get libraries");
enabled: PlexSettingsState.can-use && !PlexSettingsState.busy;
clicked => { PlexAuthActions.load-sections(); }
}
}
}
// Status line (resolved (key,args) -> text; highlighted when ok).
if PlexSettingsState.status-text != "": Text {
text: PlexSettingsState.status-text;
color: PlexSettingsState.status-kind == 3
? #e0564f
: (PlexSettingsState.status-kind == 2 ? #3fae6a : Theme.text-muted);
font-size: Typography.legal;
wrap: word-wrap;
}
Rectangle { height: 6px; }
// --- Section multi-select grid ------------------------------------
if PlexSettingsState.sections.length > 0: VerticalLayout {
spacing: 2px;
GroupHeader { text: @tr("MUSIC LIBRARIES"); }
Rectangle { height: 4px; }
Rectangle {
height: Math.min(PlexSettingsState.sections.length * 44px, 264px);
ScrollView {
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 2px;
for sec in PlexSettingsState.sections: Rectangle {
height: 42px;
border-radius: Radius.sm;
background: sec-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 10px;
padding-right: 12px;
spacing: 12px;
VerticalLayout {
alignment: center;
QbzCheckbox {
checked: sec.selected;
toggled => { PlexAuthActions.toggle-section(sec.key); }
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: sec.title;
color: Theme.text-primary;
font-size: Typography.body;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
Text {
text: @tr("({} tracks)", sec.count);
color: Theme.text-muted;
font-size: Typography.legal;
}
}
}
sec-ta := TouchArea {
mouse-cursor: pointer;
clicked => { PlexAuthActions.toggle-section(sec.key); }
}
}
}
}
}
Rectangle { height: 14px; }
}
// --- Metadata write toggle ----------------------------------------
SettingRow {
label: @tr("Write metadata to Plex (experimental)");
description: @tr("Allow QBZ to write track metadata back to your Plex server.");
VerticalLayout {
alignment: center;
QbzToggle {
checked: PlexSettingsState.metadata-write-enabled;
toggled(v) => {
PlexSettingsState.metadata-write-enabled = v;
PlexAuthActions.metadata-write-toggle(v);
}
}
}
}
Rectangle { height: 18px; }
// --- Disconnect / Clear cache (danger) ----------------------------
GroupHeader { text: @tr("PLEX DANGER ZONE"); }
SettingRow {
label: @tr("Disconnect");
description: @tr("Sign out of Plex and clear the local cache.");
SecondaryButton {
label: @tr("Disconnect");
danger: true;
enabled: PlexSettingsState.token != "";
clicked => { PlexAuthActions.disconnect(); }
}
}
SettingRow {
label: @tr("Clear cache");
description: @tr("Remove cached Plex libraries and tracks. Your sign-in is kept.");
SecondaryButton {
label: @tr("Clear cache");
danger: true;
enabled: PlexSettingsState.token != "";
clicked => { PlexAuthActions.clear-cache(); }
}
}
}
}
+69
View File
@@ -780,6 +780,75 @@ export global LibraryManageActions {
callback set-filter(string /* query */);
}
// ============================ Plex connection =============================
// Settings > Local Library > Plex. A LAN-only Plex Media Server connection:
// PIN-based auth (or manual token), library-section multi-select, and the
// per-section track caching that downstream browse views read. State is
// Rust-backed (qbz_app::settings::plex store + qbz-plex core); the controller
// lives in `plex_auth`. ADR-008: no pills. Status is a (key, args) pair so the
// Rust side can localize without 5-file JSON sync — `status-text` is the
// already-resolved label, `status-kind` drives the highlight style.
// One Plex music-library section row + its UI selection + cached track count.
export struct PlexSectionItem {
key: string, // Plex Directory@key
title: string, // section title
count: int, // cached track count (plex_cache_get_tracks(key).len())
selected: bool, // membership in selected_section_keys
}
export global PlexSettingsState {
// --- Persisted toggles -------------------------------------------------
in-out property <bool> enabled: false; // master toggle (default OFF)
in-out property <bool> ui-collapsed: false; // collapse chevron
in-out property <bool> metadata-write-enabled: false;
// --- Connection --------------------------------------------------------
// server-url is the RAW host-form text the user types; base-url is the
// Rust-resolved proto://host:port (set after resolve).
in-out property <string> server-url: "";
in property <string> base-url: "";
in property <string> token: ""; // "" = not authenticated
in-out property <bool> manual-token-mode: false;
in-out property <string> token-input: ""; // manual-token field buffer
// Derived gates (computed Rust-side, pushed in).
in property <bool> is-local-address: true; // isLocalPlexAddress(server-url)
in property <bool> can-use: false; // enabled && local && base!="" && token!=""
// --- PIN flow ----------------------------------------------------------
in property <bool> pin-busy: false; // PIN start / poll in flight
in property <string> pin-code: ""; // "" = no active code
in property <string> pin-auth-url: "";
// --- General busy + status --------------------------------------------
in property <bool> busy: false; // ping / sync / auto-setup
// Status as a resolved label + kind (0 none, 1 info, 2 connected/ok, 3 error).
in property <string> status-text: "";
in property <int> status-kind: 0;
// --- Library sections --------------------------------------------------
in property <[PlexSectionItem]> sections: [];
}
export global PlexAuthActions {
callback enable-toggle(bool);
callback collapse-toggle(bool);
callback set-server-url(string /* host-form */);
callback generate-code(); // PIN start + begin poll
callback open-auth-url();
callback copy-code();
callback manual-token-toggle(bool);
callback set-token(string /* token */); // save manual token
callback ping(); // check connection
callback load-sections(); // get libraries
callback toggle-section(string /* key */);
callback metadata-write-toggle(bool);
callback disconnect(); // confirm + reset
callback clear-cache(); // confirm + plex_cache_clear
callback load(); // panel init: seed from store + warm cache
}
// ============================ Tag editor ==================================
// Local-album metadata editor (Settings-independent modal launched from the
// local album detail). Sidecar (default) + opt-in direct file-write. Remote