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:
11 files changed
+2362
-12
No files matched your search
Generated
+1
@@ -5471,6 +5471,7 @@ dependencies = [
|
||||
"qbz-models",
|
||||
"qbz-offline-cache",
|
||||
"qbz-player",
|
||||
"qbz-plex",
|
||||
"qbz-qobuz",
|
||||
"qconnect-app",
|
||||
"qconnect-transport-ws",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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(§ions);
|
||||
let items = build_section_items(§ions, &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(§ions))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let items = build_section_items(§ions, &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(§ions, &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(§ions, &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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user