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
@@ -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