fix(qt): resolve artwork per VISIBLE row again, not per row of the document

The albums grid got slower and the owner was right about it. This is the
cause and it is mine.

Stage 4 moved the artwork decision from window time to MAP time so that a
row's provenance would still exist when it was made. That fixed bug 3 and
introduced a worse one: a remote source answers "what is this token" by
reading its CREDENTIALS, and the map runs over every row of the
document, not over the ~50 on screen. `local_plex::settings()` is a
`load_last_user_id` file read, a mutex and a SQLite query — per Plex row.

Measured on the owner's library, release, same grid each time:

  before stage 4 (a string clone)      ~2 µs/row
  after  stage 4 (resolve per row)   39-92 µs/row   <- the regression
  + memoised settings                  ~9 µs/row
  + resolve at window time again      ~1.8 µs/row

  1703 rows: map 67-71 ms  ->  3.0-3.7 ms

The art index now holds `(SourceId, raw token)` instead of a resolved
`ArtRef`. Provenance still survives — nobody sniffs a string, which was
the whole point — but the resolution happens where it always did, in
`resolve_window_blocking`, over the keys actually on screen. Same
information, right place.

The settings memo stays, in both `local_plex` and `media_servers_qt`:
those accessors are cheap gates that several callers ask repeatedly, and
every writer invalidates so a connect, a disconnect or a user switch is
still seen on the next read.

`search_local` keeps resolving inline on purpose — it is the cortinilla's
bounded 76-row result set, and the url/path split it needs is per row by
definition.

428/428. Grid verified in the running window: Plex, Jellyfin and local
badges all correct, covers resolving.

cc
This commit is contained in:
vicrodh committed 2026-08-20 12:12:22 -06:00
1 parent f2d8111721
commit a3aff5f1b3
10 files changed
+183 -74

No files matched your search

+3 -2
View File
@@ -185,8 +185,9 @@ pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
.find_map(|t| t.artwork_path.as_ref().filter(|p| !p.is_empty())) .find_map(|t| t.artwork_path.as_ref().filter(|p| !p.is_empty()))
{ {
let source = tracks.first().and_then(|t| t.source.as_deref()); let source = tracks.first().and_then(|t| t.source.as_deref());
art.entry(row.art_key.clone()) if let Some(t) = crate::local_rows::art_token(source, p) {
.or_insert_with(|| crate::local_rows::art_ref(source, p)); art.entry(row.art_key.clone()).or_insert(t);
}
} }
tracks.iter().map(|t| map_track(t, art)).collect::<Vec<TrackRow>>() tracks.iter().map(|t| map_track(t, art)).collect::<Vec<TrackRow>>()
}); });
+5 -4
View File
@@ -229,10 +229,11 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
.map(|m| { .map(|m| {
let key = artist_key(&m.name); let key = artist_key(&m.name);
if !m.image_path.is_empty() { if !m.image_path.is_empty() {
art.insert( if let Some(t) =
key.clone(), crate::local_rows::art_token(Some(&m.image_source), &m.image_path)
crate::local_rows::art_ref(Some(&m.image_source), &m.image_path), {
); art.insert(key.clone(), t);
}
} }
ArtistRow { ArtistRow {
art_key: key, art_key: key,
+13 -4
View File
@@ -70,7 +70,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock, Mutex, RwLock}; use std::sync::{Arc, LazyLock, Mutex, RwLock};
use qbz_source::ArtRef; use qbz_source::{ArtRef, ArtSize};
use crate::artwork_qt; use crate::artwork_qt;
use crate::local_state::state; use crate::local_state::state;
@@ -129,19 +129,28 @@ pub struct ArtworkWindow {
/// Keys with no cover are dropped, so the QML map only ever grows with real /// Keys with no cover are dropped, so the QML map only ever grows with real
/// hits. /// hits.
pub fn resolve_window_blocking(keys: Vec<String>) -> ArtworkWindow { pub fn resolve_window_blocking(keys: Vec<String>) -> ArtworkWindow {
let sources: Vec<(String, ArtRef)> = state(|s| { let sources: Vec<(String, qbz_source::SourceId, String)> = state(|s| {
keys.iter() keys.iter()
.filter_map(|k| s.art_index.get(k).map(|p| (k.clone(), p.clone()))) .filter_map(|k| {
s.art_index
.get(k)
.map(|(src, tok)| (k.clone(), *src, tok.clone()))
})
.collect() .collect()
}); });
let mut hits = Vec::with_capacity(sources.len()); let mut hits = Vec::with_capacity(sources.len());
let mut plex_misses = Vec::new(); let mut plex_misses = Vec::new();
let mut cold = Vec::new(); let mut cold = Vec::new();
let mut seen: HashSet<String> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
for (key, art) in sources { for (key, src, token) in sources {
if !seen.insert(key.clone()) { if !seen.insert(key.clone()) {
continue; continue;
} }
// THE resolution, and it happens HERE — over the keys on screen, not
// over every row of the document. A remote source answers by reading
// its credentials, so doing this per mapped row cost 39-92 µs each on
// a 1703-row grid that rebuilds on every visit.
let art = qbz_source::registry().artwork_token(src, &token, ArtSize::Card);
// The taxonomy is READ, not re-derived. `artwork_qt::classify` used to // The taxonomy is READ, not re-derived. `artwork_qt::classify` used to
// stand here and sniff a `String` whose provenance had been thrown // stand here and sniff a `String` whose provenance had been thrown
// away three layers up; the row's own source answered this question at // away three layers up; the row's own source answered this question at
+3 -4
View File
@@ -192,10 +192,9 @@ fn build_doc(name: &str, path: &str, tracks: &[LocalTrack]) -> EphemeralDoc {
// key of an indexed album that happens to group the same. // key of an indexed album that happens to group the same.
let art_key = album_key(&format!("eph:{key}")); let art_key = album_key(&format!("eph:{key}"));
if let Some(p) = first.artwork_path.as_ref().filter(|p| !p.is_empty()) { if let Some(p) = first.artwork_path.as_ref().filter(|p| !p.is_empty()) {
art.insert( if let Some(t) = crate::local_rows::art_token(first.source.as_deref(), p) {
art_key.clone(), art.insert(art_key.clone(), t);
crate::local_rows::art_ref(first.source.as_deref(), p), }
);
} }
let rows: Vec<TrackRow> = group.iter().map(|t| map_track(*t, art)).collect(); let rows: Vec<TrackRow> = group.iter().map(|t| map_track(*t, art)).collect();
EphemeralAlbumBlock { EphemeralAlbumBlock {
+57 -2
View File
@@ -25,7 +25,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex, RwLock};
use qbz_app::user_data::UserDataPaths; use qbz_app::user_data::UserDataPaths;
use qbz_library::LocalTrack; use qbz_library::LocalTrack;
@@ -49,6 +49,43 @@ static STATE: LazyLock<PlexSettingsState> = LazyLock::new(PlexSettingsState::new
/// The user id the store is currently bound to (None = session-less). /// The user id the store is currently bound to (None = session-less).
static BOUND: Mutex<Option<u64>> = Mutex::new(None); static BOUND: Mutex<Option<u64>> = Mutex::new(None);
/// The last read of [`settings`], kept so the accessor is not a database hit.
///
/// # Why this exists — it is a measured regression, not a micro-optimisation
///
/// `settings()` runs `ensure_bound()` (a `load_last_user_id` file read plus a
/// mutex) and then a SQLite query. That was fine while its callers were gates
/// asked a handful of times per view.
///
/// Design 02 §9 stage 4 put it on a PER-ROW path: `local_rows::art_ref` asks
/// the owning source what a row's artwork token means, and `PlexSource`'s
/// answer depends on whether Plex is connected — so every Plex row in the grid
/// paid a file read, a mutex and a query. Measured on the owner's library:
///
/// ```text
/// 29 local rows map 0.06 ms -> 2 µs/row
/// 1271 Plex rows map 50-117 ms -> 39-92 µs/row
/// ```
///
/// 25-45x per row, on a document that is rebuilt on every visit to Local
/// Library. The grid had been tuned to a good place and this is what took it
/// away.
///
/// The cache is INVALIDATED by every writer in this module and by
/// `init_for_user` / `reset`, so a connect, a disconnect or a user switch is
/// still seen immediately — the property `PlexCredsGlue` documents (it reads
/// the live store rather than caching a copy) is preserved, because the
/// authority itself is what caches now.
static CACHE: RwLock<Option<PlexSettings>> = RwLock::new(None);
/// Drop the memo. Call from EVERY writer — a stale `enabled` here is a Plex
/// library that will not disappear when the user switches it off.
fn invalidate_cache() {
if let Ok(mut c) = CACHE.write() {
*c = None;
}
}
/// Bind (or re-bind) the store to the ACTIVE user. Called lazily by every /// Bind (or re-bind) the store to the ACTIVE user. Called lazily by every
/// accessor, so no glue in the login path is required; `init_for_user` is /// accessor, so no glue in the login path is required; `init_for_user` is
/// provided for the shell to call explicitly on session activation. /// provided for the shell to call explicitly on session activation.
@@ -78,6 +115,7 @@ fn ensure_bound() {
/// Explicit bind on session activation (optional — accessors self-bind). /// Explicit bind on session activation (optional — accessors self-bind).
pub fn init_for_user(base_dir: &std::path::Path) { pub fn init_for_user(base_dir: &std::path::Path) {
invalidate_cache();
if let Err(e) = STATE.init_at(base_dir) { if let Err(e) = STATE.init_at(base_dir) {
log::warn!("[qbz-qt] plex settings init failed: {e}"); log::warn!("[qbz-qt] plex settings init failed: {e}");
return; return;
@@ -87,14 +125,24 @@ pub fn init_for_user(base_dir: &std::path::Path) {
/// Forget the binding (logout) so the next read re-binds to the new user. /// Forget the binding (logout) so the next read re-binds to the new user.
pub fn reset() { pub fn reset() {
invalidate_cache();
*BOUND.lock().unwrap() = None; *BOUND.lock().unwrap() = None;
let _ = STATE.teardown(); let _ = STATE.teardown();
} }
/// Current persisted settings (defaults when there is no session). /// Current persisted settings (defaults when there is no session).
pub fn settings() -> PlexSettings { pub fn settings() -> PlexSettings {
if let Ok(c) = CACHE.read() {
if let Some(s) = c.as_ref() {
return s.clone();
}
}
ensure_bound(); ensure_bound();
STATE.get_settings().unwrap_or_default() let fresh = STATE.get_settings().unwrap_or_default();
if let Ok(mut c) = CACHE.write() {
*c = Some(fresh.clone());
}
fresh
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -348,6 +396,7 @@ pub fn cached_sections() -> (Vec<qbz_plex::PlexMusicSection>, Vec<String>) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn set_enabled(value: bool) { pub fn set_enabled(value: bool) {
invalidate_cache();
ensure_bound(); ensure_bound();
if let Err(e) = STATE.set_enabled(value) { if let Err(e) = STATE.set_enabled(value) {
log::error!("[qbz-qt] plex set_enabled failed: {e}"); log::error!("[qbz-qt] plex set_enabled failed: {e}");
@@ -370,6 +419,7 @@ pub fn client_id() -> String {
/// through a PIN. The PIN path clears it on success so the panel goes back to /// through a PIN. The PIN path clears it on success so the panel goes back to
/// showing Authorize (`plex_auth.rs:531`). /// showing Authorize (`plex_auth.rs:531`).
pub fn set_manual_token_mode(value: bool) { pub fn set_manual_token_mode(value: bool) {
invalidate_cache();
ensure_bound(); ensure_bound();
if let Err(e) = STATE.set_manual_token_mode(value) { if let Err(e) = STATE.set_manual_token_mode(value) {
log::error!("[qbz-qt] plex set_manual_token_mode failed: {e}"); log::error!("[qbz-qt] plex set_manual_token_mode failed: {e}");
@@ -383,6 +433,7 @@ pub fn set_manual_token_mode(value: bool) {
/// `ping_inner` — so a Qt-only install stamped `server_id = NULL` on every /// `ping_inner` — so a Qt-only install stamped `server_id = NULL` on every
/// cached row. Porting the ping is what closes that. /// cached row. Porting the ping is what closes that.
pub fn set_machine_id(value: &str) { pub fn set_machine_id(value: &str) {
invalidate_cache();
ensure_bound(); ensure_bound();
if let Err(e) = STATE.set_machine_id(value) { if let Err(e) = STATE.set_machine_id(value) {
log::error!("[qbz-qt] plex set_machine_id failed: {e}"); log::error!("[qbz-qt] plex set_machine_id failed: {e}");
@@ -400,6 +451,7 @@ pub fn credentials() -> (String, String) {
// That settings surface is not ported yet, so nothing calls this today. // That settings surface is not ported yet, so nothing calls this today.
#[allow(dead_code)] #[allow(dead_code)]
pub fn set_metadata_write_enabled(value: bool) { pub fn set_metadata_write_enabled(value: bool) {
invalidate_cache();
ensure_bound(); ensure_bound();
if let Err(e) = STATE.set_metadata_write_enabled(value) { if let Err(e) = STATE.set_metadata_write_enabled(value) {
log::error!("[qbz-qt] plex set_metadata_write_enabled failed: {e}"); log::error!("[qbz-qt] plex set_metadata_write_enabled failed: {e}");
@@ -410,6 +462,7 @@ pub fn set_metadata_write_enabled(value: bool) {
/// is RESOLVED (`proto://host:32400`) before it is stored. Returns the /// is RESOLVED (`proto://host:32400`) before it is stored. Returns the
/// resolved base url ("" when the input is unusable). /// resolved base url ("" when the input is unusable).
pub fn connect_manual(server_url: &str, token: &str) -> String { pub fn connect_manual(server_url: &str, token: &str) -> String {
invalidate_cache();
ensure_bound(); ensure_bound();
let base = resolve_base_url(server_url); let base = resolve_base_url(server_url);
if base.is_empty() { if base.is_empty() {
@@ -424,6 +477,7 @@ pub fn connect_manual(server_url: &str, token: &str) -> String {
} }
pub fn set_selected_sections(keys: &[String]) { pub fn set_selected_sections(keys: &[String]) {
invalidate_cache();
ensure_bound(); ensure_bound();
if let Err(e) = STATE.set_selected_section_keys(keys) { if let Err(e) = STATE.set_selected_section_keys(keys) {
log::error!("[qbz-qt] plex set_selected_section_keys failed: {e}"); log::error!("[qbz-qt] plex set_selected_section_keys failed: {e}");
@@ -434,6 +488,7 @@ pub fn set_selected_sections(keys: &[String]) {
/// `client_id`, `metadata_write_enabled`) and purge the shared cache DB so /// `client_id`, `metadata_write_enabled`) and purge the shared cache DB so
/// no Plex rows survive in the browse union. /// no Plex rows survive in the browse union.
pub fn disconnect() { pub fn disconnect() {
invalidate_cache();
ensure_bound(); ensure_bound();
if let Err(e) = STATE.disconnect() { if let Err(e) = STATE.disconnect() {
log::error!("[qbz-qt] plex disconnect failed: {e}"); log::error!("[qbz-qt] plex disconnect failed: {e}");
+28 -39
View File
@@ -23,7 +23,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use qbz_library::{AudioFormat, LocalAlbum, LocalTrack}; use qbz_library::{AudioFormat, LocalAlbum, LocalTrack};
use qbz_source::{ArtRef, ArtSize}; use qbz_source::SourceId;
use serde::Serialize; use serde::Serialize;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -285,43 +285,28 @@ pub fn badge_source_raw(raw: Option<&str>) -> String {
} }
} }
/// The [`ArtRef`] for a row's raw artwork token, decided by the row's OWN /// `(owning source, raw token)` for a row's artwork — the pair the artwork
/// source (design 02 §9 stage 4). /// window needs in order to resolve it WITHOUT guessing.
/// ///
/// The art index used to hold the token as a bare `String`, and /// This is the cheap half of what stage 4 set out to do, and the split matters.
/// `artwork_qt::classify` sniffed it back apart later — by which point the row /// Deciding WHOSE token this is costs one vocabulary lookup and happens here,
/// was gone and all that was left was the shape of the characters. That is bug /// per row, while the row's provenance still exists — that is bug 3's fix.
/// 3, and its concrete failure is not hypothetical: `classify` knows a Plex /// Deciding what the token RESOLVES TO costs a credentials read for a remote
/// thumb only because `local_plex::is_thumb_path` was bolted onto it, so a /// source, so it happens in `local_artwork::resolve_window_blocking`, over the
/// Jellyfin token (`/Items/<id>/Images/Primary`) matches its `starts_with('/')` /// ~50 keys actually on screen rather than all 1703 in the document.
/// arm and the pipeline tries to open it as a file on disk — blank cover, no
/// error, nothing in the log.
/// ///
/// Registering the answer HERE is the fix: this is the last moment the row's /// Resolving here instead was measured at 39-92 µs/row against 2 µs, on a
/// provenance exists. `SourceId::from_word` is the one vocabulary table /// document rebuilt on every visit to Local Library. Same information, wrong
/// (§3.1), so all six source words land on the right source, and an unknown /// place.
/// word gets `None` rather than being guessed into Qobuz. pub fn art_token(source_word: Option<&str>, token: &str) -> Option<(SourceId, String)> {
/// let token = token.trim();
/// Cheap by contract — `Source::artwork_token` may not touch the network, a DB if token.is_empty() {
/// or the filesystem — so this stays a per-row call on a page load. return None;
pub fn art_ref(source_word: Option<&str>, token: &str) -> ArtRef {
if token.trim().is_empty() {
return ArtRef::None;
}
// The grid's size class. `ArtSize::Card` is `artwork_qt::PLEX_THUMB_PX`
// (256), which is deliberately ONE app-wide Plex transcode size so a cover
// is downloaded once — do not introduce a second size here.
match qbz_source::SourceId::from_word(source_word.unwrap_or("")) {
Some(id) => qbz_source::registry().artwork_token(id, token, ArtSize::Card),
// No word at all is the LOCAL case in practice (`local_tracks.source`
// is empty for a plain scanned file, §3.1's vocabulary table), and it
// is what `classify` did with those rows too.
None => qbz_source::registry().artwork_token(
qbz_source::SourceId::LOCAL,
token,
ArtSize::Card,
),
} }
// No word at all is the LOCAL case in practice (`local_tracks.source` is
// empty for a plain scanned file, §3.1's vocabulary table).
let id = SourceId::from_word(source_word.unwrap_or("")).unwrap_or(SourceId::LOCAL);
Some((id, token.to_string()))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -329,10 +314,12 @@ pub fn art_ref(source_word: Option<&str>, token: &str) -> ArtRef {
// see `art_ref`: the row's own source decides what its token means) // see `art_ref`: the row's own source decides what its token means)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, ArtRef>) -> AlbumRow { pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -> AlbumRow {
let key = album_key(&a.id); let key = album_key(&a.id);
if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) { if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) {
art.insert(key.clone(), art_ref(Some(a.source.as_str()), p)); if let Some(t) = art_token(Some(a.source.as_str()), p) {
art.insert(key.clone(), t);
}
} }
let folder_count = a let folder_count = a
.source_folders .source_folders
@@ -358,10 +345,12 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, ArtRef>) -> AlbumRow {
} }
} }
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, ArtRef>) -> TrackRow { pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, (SourceId, String)>) -> TrackRow {
let key = track_key(t.id); let key = track_key(t.id);
if let Some(p) = t.artwork_path.as_ref().filter(|p| !p.is_empty()) { if let Some(p) = t.artwork_path.as_ref().filter(|p| !p.is_empty()) {
art.insert(key.clone(), art_ref(t.source.as_deref(), p)); if let Some(tok) = art_token(t.source.as_deref(), p) {
art.insert(key.clone(), tok);
}
} }
TrackRow { TrackRow {
id: t.id.to_string(), id: t.id.to_string(),
+15 -10
View File
@@ -12,7 +12,7 @@ use std::sync::Mutex;
use qbz_app::user_data::UserDataPaths; use qbz_app::user_data::UserDataPaths;
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack}; use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
use qbz_source::ArtRef; use qbz_source::SourceId;
use crate::local_rows::{AlbumRow, ArtistRow, LocalCounts, TrackRow, TreeNode}; use crate::local_rows::{AlbumRow, ArtistRow, LocalCounts, TrackRow, TreeNode};
@@ -97,15 +97,20 @@ pub struct LocalState {
/// The FULL flattened tree (visible derivation applies the rail search). /// The FULL flattened tree (visible derivation applies the rail search).
pub tree: Vec<TreeNode>, pub tree: Vec<TreeNode>,
pub tree_search: String, pub tree_search: String,
/// artKey -> what the row's OWN source said its artwork token means /// artKey -> `(the source that owns the row, its RAW artwork token)`.
/// (design 02 §9 stage 4). The artwork window turns these into 256px
/// thumbnails (`ArtRef::File`) or disk-cached fetches (`ArtRef::Fetch`).
/// ///
/// It used to hold the raw token as a `String`, and `artwork_qt::classify` /// It used to hold the token alone, and `artwork_qt::classify` sniffed the
/// sniffed it back apart at window time — by which point the row was gone /// provenance back out of the characters at window time — which is bug 3.
/// and only the shape of the characters was left. That is bug 3; /// It briefly held a fully resolved `ArtRef` instead, which fixed that but
/// `local_rows::art_ref` resolves it while provenance still exists. /// moved the resolution from ~50 VISIBLE rows to all 1703 rows of the
pub art_index: HashMap<String, ArtRef>, /// document, and a Plex row's resolution reads its credentials: measured
/// 39-92 µs/row against 2 µs for a row that needs none.
///
/// Carrying the SOURCE beside the token keeps both properties. Provenance
/// survives — nobody sniffs anything — and the resolution happens where it
/// always did, in `local_artwork::resolve_window_blocking`, over the keys
/// actually on screen.
pub art_index: HashMap<String, (SourceId, String)>,
/// Album identity ("folder" | "metadata") — persisted (locallibrary_ui). /// Album identity ("folder" | "metadata") — persisted (locallibrary_ui).
pub album_mode: String, pub album_mode: String,
pub counts: LocalCounts, pub counts: LocalCounts,
@@ -139,7 +144,7 @@ pub fn state<R>(f: impl FnOnce(&mut LocalState) -> R) -> R {
/// Take the art index out, run `f` with it, put it back — the shape every /// Take the art index out, run `f` with it, put it back — the shape every
/// loader uses so mapping can register covers without a second lock. /// loader uses so mapping can register covers without a second lock.
pub fn with_art<R>(f: impl FnOnce(&mut HashMap<String, ArtRef>) -> R) -> R { pub fn with_art<R>(f: impl FnOnce(&mut HashMap<String, (SourceId, String)>) -> R) -> R {
let mut art = state(|s| std::mem::take(&mut s.art_index)); let mut art = state(|s| std::mem::take(&mut s.art_index));
let out = f(&mut art); let out = f(&mut art);
state(|s| s.art_index = art); state(|s| s.art_index = art);
+9 -5
View File
@@ -18,9 +18,9 @@ use std::sync::Mutex;
use qbz_library::{FolderTreeEntry, LibraryError, LocalTrack}; use qbz_library::{FolderTreeEntry, LibraryError, LocalTrack};
use serde::Serialize; use serde::Serialize;
use qbz_source::ArtRef; use qbz_source::SourceId;
use crate::local_rows::{art_ref, basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode}; use crate::local_rows::{art_token, basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode};
use crate::local_state::{state, with_art, with_db}; use crate::local_state::{state, with_art, with_db};
/// `list_folder_children` + the reference's on-disk cover fallback for the /// `list_folder_children` + the reference's on-disk cover fallback for the
@@ -63,7 +63,7 @@ fn folder_children_with_covers(path: &str) -> Vec<FolderTreeEntry> {
fn entry_to_node( fn entry_to_node(
e: &FolderTreeEntry, e: &FolderTreeEntry,
depth: i32, depth: i32,
art: &mut std::collections::HashMap<String, ArtRef>, art: &mut std::collections::HashMap<String, (SourceId, String)>,
) -> TreeNode { ) -> TreeNode {
match e { match e {
FolderTreeEntry::Folder { FolderTreeEntry::Folder {
@@ -76,7 +76,9 @@ fn entry_to_node(
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) { if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
// The folder tree is `library.db`: every cover here is a // The folder tree is `library.db`: every cover here is a
// local file. // local file.
art.insert(key.clone(), art_ref(Some("local"), a)); if let Some(t) = art_token(Some("local"), a) {
art.insert(key.clone(), t);
}
} }
TreeNode { TreeNode {
path: path.clone(), path: path.clone(),
@@ -412,7 +414,9 @@ pub fn load_folder_detail_blocking(path: &str) -> FolderDetail {
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) { if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
// The folder tree is `library.db`: every cover here is a // The folder tree is `library.db`: every cover here is a
// local file. // local file.
art.insert(key.clone(), art_ref(Some("local"), a)); if let Some(t) = art_token(Some("local"), a) {
art.insert(key.clone(), t);
}
} }
Some(SubfolderRow { Some(SubfolderRow {
path: path.clone(), path: path.clone(),
+42 -3
View File
@@ -17,15 +17,36 @@
//! beside the rest of the registry's injections, so there is one place to look //! beside the rest of the registry's injections, so there is one place to look
//! when a source answers "not configured". //! when a source answers "not configured".
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::LazyLock; use std::sync::{LazyLock, RwLock};
use qbz_app::settings::media_servers::{MediaServerKind, MediaServerSettings, MediaServerState}; use qbz_app::settings::media_servers::{MediaServerKind, MediaServerSettings, MediaServerState};
static STATE: LazyLock<MediaServerState> = LazyLock::new(MediaServerState::new); static STATE: LazyLock<MediaServerState> = LazyLock::new(MediaServerState::new);
/// The last read per server, so [`get`] is not a database hit.
///
/// SAME defect as `local_plex`'s, introduced the same way: `local_rows::art_ref`
/// asks the owning source per ROW what an artwork token means, and both media
/// sources answer by looking at whether they are connected. Without this, every
/// remote row in the grid costs a mutex plus a SQLite query — measured at
/// 39-92 µs/row against 2 µs for a row whose source needs no credentials.
///
/// Invalidated by [`put`], [`disconnect`], [`init_for_user`] and [`reset`], so
/// a connect or a master-toggle flip is still seen on the very next read.
static CACHE: LazyLock<RwLock<HashMap<&'static str, MediaServerSettings>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
fn invalidate_cache() {
if let Ok(mut c) = CACHE.write() {
c.clear();
}
}
/// Bind the store to the active user. Called from `auth_qt`. /// Bind the store to the active user. Called from `auth_qt`.
pub fn init_for_user(base_dir: &std::path::Path) { pub fn init_for_user(base_dir: &std::path::Path) {
invalidate_cache();
STATE.init_at(base_dir); STATE.init_at(base_dir);
// Mint the two stable identifiers ONCE, now, rather than at first use. // Mint the two stable identifiers ONCE, now, rather than at first use.
// Both are load-bearing and both are easy to get wrong lazily: a Jellyfin // Both are load-bearing and both are easy to get wrong lazily: a Jellyfin
@@ -35,19 +56,31 @@ pub fn init_for_user(base_dir: &std::path::Path) {
} }
pub fn reset() { pub fn reset() {
invalidate_cache();
STATE.reset(); STATE.reset();
} }
pub fn get(kind: MediaServerKind) -> MediaServerSettings { pub fn get(kind: MediaServerKind) -> MediaServerSettings {
STATE.get(kind) if let Ok(c) = CACHE.read() {
if let Some(s) = c.get(kind.as_str()) {
return s.clone();
}
}
let fresh = STATE.get(kind);
if let Ok(mut c) = CACHE.write() {
c.insert(kind.as_str(), fresh.clone());
}
fresh
} }
pub fn put(kind: MediaServerKind, s: &MediaServerSettings) { pub fn put(kind: MediaServerKind, s: &MediaServerSettings) {
STATE.put(kind, s); STATE.put(kind, s);
invalidate_cache();
} }
pub fn disconnect(kind: MediaServerKind) { pub fn disconnect(kind: MediaServerKind) {
STATE.disconnect(kind); STATE.disconnect(kind);
invalidate_cache();
} }
/// Which remote sources the Local Library union may show. /// Which remote sources the Local Library union may show.
@@ -57,7 +90,13 @@ pub fn disconnect(kind: MediaServerKind) {
/// credentials yet would widen the union's `source IN (…)` over rows nothing /// credentials yet would widen the union's `source IN (…)` over rows nothing
/// ever swept. /// ever swept.
pub fn configured_words() -> Vec<&'static str> { pub fn configured_words() -> Vec<&'static str> {
STATE.configured_words() // Through the CACHED `get`, not `STATE.configured_words()` — this is called
// once per albums-page load and twice more via `remote_cache_path`.
MediaServerKind::ALL
.iter()
.filter(|k| get(**k).is_configured(**k))
.map(|k| k.as_str())
.collect()
} }
/// The shared remote mirror for the active user, or `None` when no remote /// The shared remote mirror for the active user, or `None` when no remote
+8 -1
View File
@@ -103,7 +103,14 @@ pub(crate) fn local_art_split(source: Option<&str>, path: Option<&str>) -> (Stri
// The ROW's source decides, not the shape of the string (design 02 §9 // The ROW's source decides, not the shape of the string (design 02 §9
// stage 4). `classify` stood here and would read a Jellyfin token as a // stage 4). `classify` stood here and would read a Jellyfin token as a
// filesystem path purely because it starts with `/`. // filesystem path purely because it starts with `/`.
match crate::local_rows::art_ref(source, p) { // Resolved here rather than deferred: this list is the cortinilla's
// bounded result set (76 rows), not the whole library, and the split it
// needs — `art_path` for a file, `art_url` for a fetch — is decided per
// row by definition.
let Some((src, token)) = crate::local_rows::art_token(source, p) else {
return (String::new(), String::new());
};
match qbz_source::registry().artwork_token(src, &token, qbz_source::ArtSize::Card) {
qbz_source::ArtRef::File(f) => ( qbz_source::ArtRef::File(f) => (
String::new(), String::new(),
crate::artwork_qt::file_url(&f.to_string_lossy()), crate::artwork_qt::file_url(&f.to_string_lossy()),