refactor(mixtape): move provider resolution to source registry

cx
This commit is contained in:
vicrodh committed 2026-08-26 21:38:58 -06:00
1 parent 910bee49a5
commit d9ed19eb64
15 files changed
+118 -331

No files matched your search

-2
View File
@@ -4375,9 +4375,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"log", "log",
"qbz-library",
"qbz-models", "qbz-models",
"qbz-plex",
"qbz-qobuz", "qbz-qobuz",
"rand 0.10.1", "rand 0.10.1",
"rusqlite", "rusqlite",
+1 -14
View File
@@ -5,23 +5,10 @@ edition = "2021"
description = "Mixtapes & Collections backend for QBZ — schema, repo, shuffle, enqueue (headless, frontend-agnostic)" description = "Mixtapes & Collections backend for QBZ — schema, repo, shuffle, enqueue (headless, frontend-agnostic)"
authors = ["blitzkriegfc"] authors = ["blitzkriegfc"]
[features]
default = ["local", "plex"]
# Resolving Mixtape items that live in the on-disk library (`qbz-library`).
local = ["dep:qbz-library"]
# Resolving `plex:`-prefixed album keys against the Plex cache. Reached through
# `resolve_local_album`, so it builds on `local`.
plex = ["local", "dep:qbz-plex"]
[dependencies] [dependencies]
# Shared QBZ crates (the real models + resolvers, not src-tauri shims) # Shared QBZ crates
qbz-models = { path = "../qbz-models" } qbz-models = { path = "../qbz-models" }
qbz-qobuz = { path = "../qbz-qobuz" } qbz-qobuz = { path = "../qbz-qobuz" }
# Optional so the schema/repo/shuffle half of this crate is reusable where there
# are no local files and no LAN peer to reach — a phone, for one. Both default
# on, so the desktop build is unchanged.
qbz-library = { path = "../qbz-library", optional = true }
qbz-plex = { path = "../qbz-plex", optional = true }
# Database # Database
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
+12 -246
View File
@@ -6,13 +6,9 @@
//! //!
//! Frontend-agnostic notes (ADR-006): //! Frontend-agnostic notes (ADR-006):
//! - The Qobuz resolvers are async free fns over `qbz_qobuz::QobuzClient`. //! - The Qobuz resolvers are async free fns over `qbz_qobuz::QobuzClient`.
//! - The local/Plex resolvers are SYNCHRONOUS free fns over //! - Provider-specific local and LAN resolution belongs to `qbz-source`.
//! `qbz_library::LibraryDatabase` / the `qbz-plex` cache. `&LibraryDatabase` //! This crate only defines collection ordering/flattening and the generic
//! wraps a `rusqlite::Connection`, which is `!Sync`, so a `&LibraryDatabase` //! resolver seam, so it does not pull provider databases into its graph.
//! is `!Send` and must NEVER be held across an `.await`. Keeping local/Plex
//! resolution in a synchronous free fn enforces that at the type level: the
//! caller does its own DB access (e.g. Slint's `with_db(|db| ...)`) and the
//! crate bakes in no specific handle type.
use qbz_models::mixtape::{AlbumSource, CollectionPlayMode, ItemType, MixtapeCollectionItem}; use qbz_models::mixtape::{AlbumSource, CollectionPlayMode, ItemType, MixtapeCollectionItem};
use qbz_models::QueueTrack as CoreQueueTrack; use qbz_models::QueueTrack as CoreQueueTrack;
@@ -21,8 +17,8 @@ use qbz_models::QueueTrack as CoreQueueTrack;
use qbz_models::Track as ApiTrack; use qbz_models::Track as ApiTrack;
/// Trait for expanding a single Mixtape item into its tracks. Implementations: /// Trait for expanding a single Mixtape item into its tracks. Implementations:
/// - `ProdItemResolver` — uses the Qobuz client + a caller-supplied local /// - `ProdItemResolver` — uses the Qobuz client plus a caller-supplied resolver
/// resolver (the real production path) /// for non-Qobuz items
/// - mocks in `#[cfg(test)]` /// - mocks in `#[cfg(test)]`
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait ItemResolver: Send + Sync { pub trait ItemResolver: Send + Sync {
@@ -153,16 +149,9 @@ pub fn previous_item_index(
// ──────────────────────────── ProdItemResolver ──────────────────────────── // ──────────────────────────── ProdItemResolver ────────────────────────────
/// Production resolver. Holds a reference to the shared Qobuz client and a /// Resolver over the shared Qobuz client plus a caller-supplied synchronous
/// caller-supplied `local` closure that resolves local/Plex items /// closure for non-Qobuz items. The closure keeps provider state and database
/// synchronously. /// handles outside this generic crate.
///
/// `&qbz_library::LibraryDatabase` is `!Send`/`!Sync` (it wraps a rusqlite
/// `Connection`), so it cannot be stored here without breaking the
/// `ItemResolver: Send + Sync` bound. Instead the caller supplies a
/// `Send + Sync` closure that performs the DB access in its own synchronous
/// scope (e.g. Slint's `with_db(|db| resolve_local_album_tracks(db, key))`).
/// This keeps the crate free of any frontend's DB-handle type.
pub struct ProdItemResolver<'a, L> pub struct ProdItemResolver<'a, L>
where where
L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync, L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync,
@@ -175,9 +164,8 @@ impl<'a, L> ProdItemResolver<'a, L>
where where
L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync, L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync,
{ {
/// Build a production resolver from the shared Qobuz client and a local /// Build a resolver from the shared Qobuz client and a non-Qobuz resolver
/// resolver closure. The closure is invoked only for `AlbumSource::Local` /// closure. The closure is invoked only for `AlbumSource::Local` items.
/// items and must perform its DB access synchronously.
pub fn new(client: &'a qbz_qobuz::QobuzClient, local: L) -> Self { pub fn new(client: &'a qbz_qobuz::QobuzClient, local: L) -> Self {
Self { client, local } Self { client, local }
} }
@@ -207,8 +195,7 @@ where
.map_err(|_| format!("invalid qobuz playlist id: {}", item.source_item_id))?; .map_err(|_| format!("invalid qobuz playlist id: {}", item.source_item_id))?;
resolve_qobuz_playlist(self.client, playlist_id).await resolve_qobuz_playlist(self.client, playlist_id).await
} }
// All local/Plex resolution is delegated to the caller-supplied // Provider resolution is delegated to the caller-supplied closure.
// synchronous closure (no `&LibraryDatabase` held across `.await`).
(_, AlbumSource::Local) => (self.local)(item), (_, AlbumSource::Local) => (self.local)(item),
} }
} }
@@ -333,122 +320,7 @@ pub async fn resolve_qobuz_playlist(
Ok(tracks.iter().map(track_to_queue_track_from_api).collect()) Ok(tracks.iter().map(track_to_queue_track_from_api).collect())
} }
// ── Local album (synchronous, frontend-agnostic) ── // ── Qobuz mapping helper ──
/// Resolve a `Local` album item's `source_item_id` into tracks.
///
/// Routes the `plex:`-prefixed keys to the Plex cache; everything else is a
/// true local album resolved against the passed `&LibraryDatabase`. Both
/// branches are synchronous — no `&LibraryDatabase` is held across an `.await`.
#[cfg(feature = "local")]
pub fn resolve_local_album(
db: &qbz_library::LibraryDatabase,
group_key: &str,
) -> Result<Vec<CoreQueueTrack>, String> {
// Plex-backed items carry a Plex album_key as their source_item_id. Those
// rows live in the Plex cache DB (plex_cache_tracks), not local_tracks,
// so route them to the Plex cache fetcher instead of db.get_album_tracks.
if group_key.starts_with("plex:") {
#[cfg(feature = "plex")]
return resolve_plex_album_tracks(group_key);
// Refused rather than looked up in local_tracks: a Plex key is not a
// local album group, and resolving it there would silently return the
// wrong tracks or none at all.
#[cfg(not(feature = "plex"))]
return Err(format!(
"plex album {} cannot be resolved: this build has no Plex support",
group_key
));
}
resolve_local_album_tracks(db, group_key)
}
/// Resolve a true local album group (no `plex:` prefix) against the library DB.
#[cfg(feature = "local")]
pub fn resolve_local_album_tracks(
db: &qbz_library::LibraryDatabase,
group_key: &str,
) -> Result<Vec<CoreQueueTrack>, String> {
let tracks = db
.get_album_tracks(group_key)
.map_err(|e| format!("local get_album_tracks({}) failed: {}", group_key, e))?;
if tracks.is_empty() {
return Err(format!("local album {} has 0 tracks", group_key));
}
Ok(tracks.iter().map(local_track_to_queue_track).collect())
}
/// Resolve a `plex:`-prefixed album key against the shared Plex cache.
#[cfg(feature = "plex")]
pub fn resolve_plex_album_tracks(group_key: &str) -> Result<Vec<CoreQueueTrack>, String> {
let tracks = qbz_plex::plex_cache_get_album_tracks(group_key.to_string())
.map_err(|e| format!("plex cache get_album_tracks({}) failed: {}", group_key, e))?;
if tracks.is_empty() {
return Err(format!(
"plex album {} has 0 tracks (cache empty — visit LocalLibrary to sync)",
group_key
));
}
Ok(tracks
.iter()
.map(plex_cached_track_to_queue_track)
.collect())
}
// ── Local track (synchronous) ──
#[cfg(feature = "local")]
pub fn resolve_local_track(
db: &qbz_library::LibraryDatabase,
track_id: i64,
) -> Result<Vec<CoreQueueTrack>, String> {
let track = db
.get_track(track_id)
.map_err(|e| format!("local get_track({}) failed: {}", track_id, e))?
.ok_or_else(|| format!("local track {} not found", track_id))?;
Ok(vec![local_track_to_queue_track(&track)])
}
/// Full local-item dispatch contract (Album / Track / Playlist), centralized in
/// the crate so frontends do NOT re-implement (and silently drift from) the
/// matrix. This is the synchronous `&LibraryDatabase` counterpart of the async
/// Qobuz resolvers; a frontend wires it into `ProdItemResolver`'s `local`
/// closure through its own DB accessor — e.g. Slint's
/// `with_db(|db| resolve_local_item(db, item))` (the closure runs in a sync
/// scope, so `&LibraryDatabase` never crosses an `.await`).
///
/// Mirrors the original src-tauri `ProdItemResolver` Local arms exactly:
/// - Album → [`resolve_local_album`] (handles the `plex:` prefix internally)
/// - Track → parse `source_item_id` to `i64` (`invalid local track id`) → [`resolve_local_track`]
/// - Playlist → hard error `local playlists not supported in this release`
#[cfg(feature = "local")]
pub fn resolve_local_item(
db: &qbz_library::LibraryDatabase,
item: &MixtapeCollectionItem,
) -> Result<Vec<CoreQueueTrack>, String> {
match item.item_type {
ItemType::Album => resolve_local_album(db, &item.source_item_id),
ItemType::Track => {
let track_id: i64 = item
.source_item_id
.parse()
.map_err(|_| format!("invalid local track id: {}", item.source_item_id))?;
resolve_local_track(db, track_id)
}
ItemType::Playlist => {
// Local playlists are not supported in this release. The library DB
// schema stores qobuz_playlist_id + local_track_id rows but there is
// no unique "local-only playlist id" to resolve against.
Err("local playlists not supported in this release".into())
}
}
}
// ── Shared mapping helpers ──
/// Map a Qobuz API `Track` to a `CoreQueueTrack`. /// Map a Qobuz API `Track` to a `CoreQueueTrack`.
pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack { pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack {
@@ -501,92 +373,6 @@ pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack {
} }
} }
/// Map a cached Plex track to a CoreQueueTrack. The Plex rating_key (numeric
/// string) becomes the QueueTrack.id so the frontend's Plex playback path
/// (`v2_plex_play_track` with `ratingKey: String(track.id)`) resolves to the
/// same Plex object. source="plex" lets playback route to the Plex branch
/// instead of the local-file branch.
#[cfg(feature = "plex")]
pub fn plex_cached_track_to_queue_track(track: &qbz_plex::PlexCachedTrack) -> CoreQueueTrack {
let id: u64 = track.rating_key.parse().unwrap_or(track.id);
let sample_rate_khz = if track.sample_rate > 0 {
Some((track.sample_rate as f64) / 1000.0)
} else {
None
};
CoreQueueTrack {
id,
title: track.title.clone(),
version: None,
artist: track.artist.clone(),
album: track.album.clone(),
album_version: None,
duration_secs: track.duration_secs,
artwork_url: track
.artwork_path
.clone()
.or_else(|| track.collection_artwork_path.clone()),
hires: track.bit_depth.map(|d| d > 16).unwrap_or(false),
bit_depth: track.bit_depth,
sample_rate: sample_rate_khz,
is_local: true,
album_id: Some(track.album_key.clone()),
artist_id: None,
streamable: true,
source: Some("plex".to_string()),
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
/// Map a `LocalTrack` to a `CoreQueueTrack`.
/// `is_local = true`, `source = "local"`, `sample_rate` is converted from Hz
/// to kHz to match the Qobuz convention used elsewhere in the queue display.
#[cfg(feature = "local")]
pub fn local_track_to_queue_track(track: &qbz_library::LocalTrack) -> CoreQueueTrack {
// Artwork: local tracks store a file path; expose it as a `file://` URL
// so the frontend's <img> can load it. Falls back to None when absent.
let artwork_url = track.artwork_path.as_ref().map(|p| {
if p.starts_with("file://") {
p.clone()
} else {
format!("file://{}", p)
}
});
// sample_rate in LocalTrack is stored in Hz (e.g. 44100.0 / 192000.0).
// CoreQueueTrack.sample_rate is in kHz (e.g. 44.1 / 192.0) matching the
// Qobuz API field `maximum_sampling_rate`. Divide by 1000.
let sample_rate_khz = track.sample_rate / 1000.0;
CoreQueueTrack {
// Local track ids are i64; CoreQueueTrack.id is u64.
// Local ids start from 1 and are never negative in practice.
id: track.id as u64,
title: track.title.clone(),
version: None,
artist: track.artist.clone(),
album: track.album_group_title.clone(),
album_version: None,
duration_secs: track.duration_secs,
artwork_url,
hires: track.bit_depth.map(|d| d > 16).unwrap_or(false),
bit_depth: track.bit_depth,
sample_rate: Some(sample_rate_khz),
is_local: true,
album_id: Some(track.album_group_key.clone()),
artist_id: None,
streamable: true,
source: Some("local".to_string()),
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
// ──────────────────────────── Tests ──────────────────────────── // ──────────────────────────── Tests ────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -657,26 +443,6 @@ mod tests {
} }
} }
#[cfg(feature = "local")]
#[test]
fn resolve_local_item_playlist_is_unsupported() {
// The (Playlist, Local) hard error is a load-bearing contract (spec §5.4/§10);
// lock it so the later Slint enqueue slice cannot silently drop it.
let db = qbz_library::LibraryDatabase::open(std::path::Path::new(":memory:")).unwrap();
let it = item(0, ItemType::Playlist, AlbumSource::Local, "whatever", 0);
let err = resolve_local_item(&db, &it).unwrap_err();
assert_eq!(err, "local playlists not supported in this release");
}
#[cfg(feature = "local")]
#[test]
fn resolve_local_item_track_rejects_non_numeric_id() {
let db = qbz_library::LibraryDatabase::open(std::path::Path::new(":memory:")).unwrap();
let it = item(0, ItemType::Track, AlbumSource::Local, "not-a-number", 0);
let err = resolve_local_item(&db, &it).unwrap_err();
assert_eq!(err, "invalid local track id: not-a-number");
}
#[tokio::test] #[tokio::test]
async fn resolver_stamps_hint_and_flattens_in_order() { async fn resolver_stamps_hint_and_flattens_in_order() {
let items = vec![ let items = vec![
+5 -8
View File
@@ -1,26 +1,23 @@
//! Mixtapes & Collections backend — schema, repository, shuffle/DJ-mix, and //! Mixtapes & Collections backend — schema, repository, shuffle/DJ-mix, and
//! enqueue/resolution logic for QBZ. //! enqueue/resolution logic for QBZ.
//! //!
//! Extracted verbatim from `src-tauri/src/mixtape/*` and re-typed against the //! Runs headless over shared models and Qobuz APIs, while provider-specific
//! real shared crates (`qbz-models`, `qbz-qobuz`, `qbz-library`, `qbz-plex`), //! local/LAN resolution remains in `qbz-source` (ADR-006).
//! so it runs headless with no Tauri state and no `#[tauri::command]` wrappers
//! (ADR-005: no legacy wrappers; ADR-006: frontend-agnostic core).
//! //!
//! - [`schema`] — SQLite migrations (`run_mixtape_migrations`). //! - [`schema`] — SQLite migrations (`run_mixtape_migrations`).
//! - [`repo`] — CRUD over `&Connection` / `&mut Connection`. //! - [`repo`] — CRUD over `&Connection` / `&mut Connection`.
//! - [`shuffle`] — pure DJ-mix sampler (`rand`, `strsim`). //! - [`shuffle`] — pure DJ-mix sampler (`rand`, `strsim`).
//! - [`enqueue`] — `ItemResolver` trait, `resolve_collection_tracks`, //! - [`enqueue`] — `ItemResolver` trait, `resolve_collection_tracks`,
//! `shuffle_items`, `next_item_index` / `previous_item_index`, plus the //! `shuffle_items`, `next_item_index` / `previous_item_index`, plus the
//! free resolver fns + `ProdItemResolver` typed against the shared crates. //! Qobuz resolver fns + `ProdItemResolver`.
pub mod enqueue; pub mod enqueue;
pub mod repo; pub mod repo;
pub mod schema; pub mod schema;
pub mod shuffle; pub mod shuffle;
// Convenience re-exports for callers (the Slint app in a later slice). // Convenience re-exports for application callers.
pub use enqueue::{ pub use enqueue::{
next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver, next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver, ProdItemResolver,
ProdItemResolver,
}; };
pub use schema::run_mixtape_migrations; pub use schema::run_mixtape_migrations;
+1 -1
View File
@@ -2,7 +2,7 @@
//! //!
//! All functions take `&Connection` (or `&mut Connection` when a transaction //! All functions take `&Connection` (or `&mut Connection` when a transaction
//! is needed). No Tauri state, no async runtime — testable with in-memory //! is needed). No Tauri state, no async runtime — testable with in-memory
//! SQLite. The Slint command layer wraps these with the app's library handle. //! SQLite. The application layer wraps these with its per-user library handle.
use rusqlite::{params, Connection, OptionalExtension, Result}; use rusqlite::{params, Connection, OptionalExtension, Result};
use uuid::Uuid; use uuid::Uuid;
+6 -9
View File
@@ -214,12 +214,10 @@ fn is_ephemeral_item(it: &AddItem) -> bool {
/// (`qbz-models/src/mixtape.rs` — `AlbumSource::Local` is an umbrella), so /// (`qbz-models/src/mixtape.rs` — `AlbumSource::Local` is an umbrella), so
/// the source is NOT consulted for an album. /// the source is NOT consulted for an album.
/// - **Mixtape**: albums, playlists and tracks. /// - **Mixtape**: albums, playlists and tracks.
/// - **A LOCAL playlist belongs nowhere.** `qbz_mixtape::enqueue:: /// - **A LOCAL playlist belongs nowhere.** The local and LAN implementations
/// resolve_local_item` (`enqueue.rs:404,417-422`) answers `(Playlist, Local)` /// in `qbz-source` reject playlist resolution; there is no local-only
/// with the hard error `"local playlists not supported in this release"` — /// playlist id to resolve against. That error is the unsupported combination
/// there is no local-only playlist id to resolve against. That error is THE /// R2 talks about, and this arm makes it unreachable from the UI.
/// unsupported combination R2 talks about; it stays where it is, as the
/// safety net, and this arm is what makes it unreachable from the UI.
/// - **Ephemeral: nothing** (R3). /// - **Ephemeral: nothing** (R3).
/// ///
/// Kind and source are read through `item_type_from_str` / `source_from_str`, /// Kind and source are read through `item_type_from_str` / `source_from_str`,
@@ -947,9 +945,8 @@ mod tests {
payload_accepts(&[add("playlist", "qobuz", "998877")]), payload_accepts(&[add("playlist", "qobuz", "998877")]),
Accepts::MIXTAPE_ONLY Accepts::MIXTAPE_ONLY
); );
// R2: `qbz_mixtape::enqueue::resolve_local_item` hard-errors on // R2: qbz-source rejects (Playlist, Local). Offering ANY container for
// (Playlist, Local). Offering ANY container for it is what makes that // it would make that error reachable, so the picker never opens.
// error reachable, so nothing is offered and the picker never opens.
let local_playlist = payload_accepts(&[add("playlist", "local", "whatever")]); let local_playlist = payload_accepts(&[add("playlist", "local", "whatever")]);
assert_eq!(local_playlist, Accepts::NONE); assert_eq!(local_playlist, Accepts::NONE);
assert!(!local_playlist.any()); assert!(!local_playlist.any());
+2 -2
View File
@@ -26,8 +26,8 @@ qbz-media-cache = { path = "../qbz-media-cache" }
# — every statement lives in `qbz-media-cache` or `qbz-library`. # — every statement lives in `qbz-media-cache` or `qbz-library`.
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
# `ItemResolver` — this crate IMPLEMENTS it (mixtape_adapter.rs). qbz-mixtape is # `ItemResolver` — this crate IMPLEMENTS it (mixtape_adapter.rs). qbz-mixtape is
# NOT modified and NOT absorbed; the dependency remains one-directional so the # provider-agnostic and the dependency remains one-directional, so the Qt
# Qt application can consume the adapter without coupling qbz-mixtape to it. # application can consume the adapter without coupling qbz-mixtape to sources.
qbz-mixtape = { path = "../qbz-mixtape" } qbz-mixtape = { path = "../qbz-mixtape" }
async-trait = { workspace = true } async-trait = { workspace = true }
+4 -8
View File
@@ -110,14 +110,10 @@ pub enum Accepted {
Release, Release,
/// A playlist — and only one this build can actually resolve. /// A playlist — and only one this build can actually resolve.
/// ///
/// `qbz_mixtape::enqueue::resolve_local_item` answers `(Playlist, Local)` /// Neither [`crate::LocalSource`] nor [`crate::PlexSource`] claims a
/// with the hard error `"local playlists not supported in this release"` /// playlist: both answer [`crate::SourceError::Unsupported`]. That error
/// (enqueue.rs:418-424: the schema has no local-only playlist id to /// is the unsupported combination R2 talks about; it stays as the safety
/// resolve against), and neither [`crate::LocalSource`] nor /// net, and this slot is what makes it unreachable from the UI.
/// [`crate::PlexSource`] claims a playlist either (`local.rs:601-606`,
/// `plex.rs:307-310` both answer `Unsupported`). That error is exactly the
/// unsupported combination R2 talks about; it stays where it is as the
/// safety net, and this slot is what makes it unreachable from the UI.
Playlist, Playlist,
/// A single track. /// A single track.
Track, Track,
+2 -2
View File
@@ -57,8 +57,8 @@ pub enum SourceError {
id: String, id: String,
}, },
/// This source does not resolve that kind. Carries `qbz-mixtape`'s "local /// This source does not resolve that kind. In particular, local and LAN
/// playlists not supported in this release" contract (enqueue.rs:418-423). /// sources do not expose playlist resolution.
#[error("{by}: {kind:?} is not supported")] #[error("{by}: {kind:?} is not supported")]
Unsupported { by: SourceId, kind: ItemKind }, Unsupported { by: SourceId, kind: ItemKind },
+3 -2
View File
@@ -12,7 +12,7 @@
//! //!
//! | concern | Qobuz path today | local path today | Plex path today | who wires it | //! | concern | Qobuz path today | local path today | Plex path today | who wires it |
//! |---|---|---|---|---| //! |---|---|---|---|---|
//! | tracks | `qbz_mixtape::resolve_qobuz_album` | `resolve_local_album_tracks` | `resolve_plex_album_tracks` | every view | //! | tracks | `qbz_mixtape::resolve_qobuz_album` | `LocalSource::tracks` | `PlexSource::tracks` | `SourceRegistry` |
//! | artwork | `artwork_qt::cached_path` | `local_artwork::local_thumbnail` | `local_plex::thumb_url` | every view | //! | artwork | `artwork_qt::cached_path` | `local_artwork::local_thumbnail` | `local_plex::thumb_url` | every view |
//! | playback | `core().play_track_resolved` | `play_local_file` | `play_plex_track` | 2 duplicated routers | //! | playback | `core().play_track_resolved` | `play_local_file` | `play_plex_track` | 2 duplicated routers |
//! | context/meta | `to_item` + resolve cache | `map_track` / `map_album` | same, plus merges | every view | //! | context/meta | `to_item` + resolve cache | `map_track` / `map_album` | same, plus merges | every view |
@@ -56,7 +56,8 @@
//! //!
//! # Shape rules //! # Shape rules
//! //!
//! - **Additive.** Nothing in `qbz/src` (Slint) or `qbz-mixtape` changes; this //! - **One provider seam.** `qbz-mixtape` retains generic collection expansion
//! and Qobuz mapping; local and LAN provider resolution lives here. This
//! crate implements `qbz_mixtape::ItemResolver`, it does not absorb it. //! crate implements `qbz_mixtape::ItemResolver`, it does not absorb it.
//! - **Nothing long-lived is cached that another crate owns.** The Qobuz client //! - **Nothing long-lived is cached that another crate owns.** The Qobuz client
//! is READ THROUGH a [`ClientLens`] on every call, because `qbz-core` //! is READ THROUGH a [`ClientLens`] on every call, because `qbz-core`
+1 -1
View File
@@ -44,7 +44,7 @@ impl QualityHint {
/// ///
/// Moved from the kHz normalisation duplicated at /// Moved from the kHz normalisation duplicated at
/// `local_playback.rs:49-53`, `local_rows::tier_of` (local_rows.rs:184-188) /// `local_playback.rs:49-53`, `local_rows::tier_of` (local_rows.rs:184-188)
/// and `qbz-mixtape/enqueue.rs:527`. /// and the Plex queue mapper in `sources/plex.rs`.
pub fn from_hz(bit_depth: Option<u32>, sample_rate_hz: f64, format: &'static str) -> Self { pub fn from_hz(bit_depth: Option<u32>, sample_rate_hz: f64, format: &'static str) -> Self {
let khz = if sample_rate_hz >= 1000.0 { let khz = if sample_rate_hz >= 1000.0 {
sample_rate_hz / 1000.0 sample_rate_hz / 1000.0
+4 -6
View File
@@ -1,11 +1,9 @@
//! Adapts the registry to `qbz_mixtape::ItemResolver`. //! Adapts the registry to `qbz_mixtape::ItemResolver`.
//! //!
//! `qbz-mixtape` is NOT modified, NOT absorbed, and stays a leaf of the graph: //! `qbz-mixtape` stays a provider-agnostic leaf of the graph: this crate
//! this crate depends on it, never the reverse. `ItemResolver`, //! depends on it, never the reverse. It retains `ItemResolver`, generic
//! `ProdItemResolver`, `resolve_collection_tracks`, `shuffle_items`, //! collection expansion and Qobuz mapping; this adapter supplies local and LAN
//! `next_item_index` and every free resolver fn stay byte-identical, so the //! dispatch through the registry.
//! `qbz` (Slint) crate keeps compiling and behaving exactly as today with zero
//! edits.
use qbz_models::mixtape::MixtapeCollectionItem; use qbz_models::mixtape::MixtapeCollectionItem;
use qbz_models::QueueTrack; use qbz_models::QueueTrack;
+6 -8
View File
@@ -568,9 +568,8 @@ impl Source for LocalSource {
} }
ItemKind::Track => Self::row_id(raw) ItemKind::Track => Self::row_id(raw)
.map(|row| MediaRef::new(SourceId::LOCAL, ItemKind::Track, row.to_string())), .map(|row| MediaRef::new(SourceId::LOCAL, ItemKind::Track, row.to_string())),
// enqueue.rs:418-423 — the library schema stores qobuz_playlist_id // The library schema stores qobuz_playlist_id + local_track_id rows
// + local_track_id rows but there is no unique "local-only playlist // but there is no unique local-only playlist id to resolve against.
// id" to resolve against.
ItemKind::Playlist => Err(SourceError::Unsupported { ItemKind::Playlist => Err(SourceError::Unsupported {
by: SourceId::LOCAL, by: SourceId::LOCAL,
kind: ItemKind::Playlist, kind: ItemKind::Playlist,
@@ -586,19 +585,18 @@ impl Source for LocalSource {
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> { async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
// No `.await` in this body: `&LibraryDatabase` is `!Send` and must never // No `.await` in this body: `&LibraryDatabase` is `!Send` and must never
// cross one (enqueue.rs:10-15). The future is ready on first poll; the // cross one. The future is ready on first poll; the
// caller keeps wrapping the call in `spawn_blocking` exactly where it // caller keeps wrapping the call in `spawn_blocking` exactly where it
// does today (local_playback.rs:302, :313, :323). // does today (local_playback.rs:302, :313, :323).
let rows: Vec<LocalTrack> = match item.kind() { let rows: Vec<LocalTrack> = match item.kind() {
// Moved from `qbz_mixtape::resolve_local_album_tracks` // Provider-owned replacement for the legacy mixtape resolver and
// (enqueue.rs:351-364) + `local_albums::fetch_album_tracks_blocking` // `local_albums::fetch_album_tracks_blocking`.
// (local_albums.rs:225-241).
ItemKind::Album => self.album_tracks(item.id()), ItemKind::Album => self.album_tracks(item.id()),
// Moved from `local_playback::play_folder` (local_playback.rs:312-319). // Moved from `local_playback::play_folder` (local_playback.rs:312-319).
ItemKind::Folder => self ItemKind::Folder => self
.with(|db| db.list_folder_tracks_recursive(item.id(), false)) .with(|db| db.list_folder_tracks_recursive(item.id(), false))
.unwrap_or_default(), .unwrap_or_default(),
// Moved from `qbz_mixtape::resolve_local_track` (enqueue.rs:381-391). // Provider-owned replacement for the legacy local-track resolver.
ItemKind::Track => { ItemKind::Track => {
let row_id = item.id().parse::<i64>().map_err(|_| SourceError::BadIdShape { let row_id = item.id().parse::<i64>().map_err(|_| SourceError::BadIdShape {
by: SourceId::LOCAL, by: SourceId::LOCAL,
+66 -16
View File
@@ -8,7 +8,7 @@
//! | path | `QueueTrack.id` | `source_item_id_hint` | //! | path | `QueueTrack.id` | `source_item_id_hint` |
//! |---|---|---| //! |---|---|---|
//! | LocalLibrary (`local_playback.rs:31-108`) | namespaced `1099511671736` | `file_path` = the raw rating key `"44440"` | //! | LocalLibrary (`local_playback.rs:31-108`) | namespaced `1099511671736` | `file_path` = the raw rating key `"44440"` |
//! | MyQBZ (`enqueue.rs:479-511` + `:52-56`) | raw `44440` | overwritten with `plex:<hash>` | //! | MyQBZ (`PlexSource::tracks` + `resolve_collection_tracks`) | raw `44440` | overwritten with `plex:<hash>` |
//! //!
//! Nobody wrote that table down: `plex_rating_key` (local_playback.rs:229-234, //! Nobody wrote that table down: `plex_rating_key` (local_playback.rs:229-234,
//! copy-pasted to local_album_actions.rs:439-450) is one engineer's //! copy-pasted to local_album_actions.rs:439-450) is one engineer's
@@ -33,12 +33,47 @@ use crate::source::Source;
/// Namespace bit for Plex track ids so they can never collide with a real /// Namespace bit for Plex track ids so they can never collide with a real
/// `local_tracks.id`. Moved from `local_plex::PLEX_TRACK_ID_FLOOR` /// `local_tracks.id`. Moved from `local_plex::PLEX_TRACK_ID_FLOOR`
/// (local_plex.rs:37), which is 1:1 with the Slint's constant. /// (local_plex.rs:37); every frontend path uses the same namespace.
pub const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40; pub const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40;
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP` (artwork_qt.rs:82). /// Memo ceiling, mirroring `artwork_qt::MEMO_CAP` (artwork_qt.rs:82).
const MEMO_CAP: usize = 8192; const MEMO_CAP: usize = 8192;
/// Map the Plex cache row into the queue shape owned by this source.
///
/// The raw numeric rating key is deliberately the queue id for MyQBZ. The
/// registry's claim path can then route playback back to the same Plex object,
/// while `resolve_collection_tracks` stamps the album boundary separately.
fn plex_queue_track(track: &PlexCachedTrack) -> QueueTrack {
let id = track.rating_key.parse().unwrap_or(track.id);
let sample_rate = (track.sample_rate > 0).then_some(track.sample_rate as f64 / 1000.0);
QueueTrack {
id,
title: track.title.clone(),
version: None,
artist: track.artist.clone(),
album: track.album.clone(),
album_version: None,
duration_secs: track.duration_secs,
artwork_url: track
.artwork_path
.clone()
.or_else(|| track.collection_artwork_path.clone()),
hires: track.bit_depth.is_some_and(|depth| depth > 16),
bit_depth: track.bit_depth,
sample_rate,
is_local: true,
album_id: Some(track.album_key.clone()),
artist_id: None,
streamable: true,
source: Some("plex".to_string()),
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
/// The Plex credentials, implemented by the frontend over its EXISTING store. /// The Plex credentials, implemented by the frontend over its EXISTING store.
/// ///
/// Plex credentials live in a per-user SQLite store `qbz-qt` already owns /// Plex credentials live in a per-user SQLite store `qbz-qt` already owns
@@ -140,8 +175,8 @@ impl PlexSource {
/// `file_path`, which for a Plex row is the server key); /// `file_path`, which for a Plex row is the server key);
/// 2. a numeric id with [`PLEX_TRACK_ID_FLOOR`] set → mask the namespace /// 2. a numeric id with [`PLEX_TRACK_ID_FLOOR`] set → mask the namespace
/// bit and RESOLVE that cache row id, never `id.to_string()`; /// bit and RESOLVE that cache row id, never `id.to_string()`;
/// 3. a bare numeric id → the rating key (the MyQBZ path, /// 3. a bare numeric id → the rating key (the MyQBZ path built by
/// `enqueue.rs:480` `rating_key.parse().unwrap_or(track.id)`); /// [`plex_queue_track`]);
/// 4. otherwise → [`SourceError::BadIdShape`]. Notably a NON-NUMERIC rating /// 4. otherwise → [`SourceError::BadIdShape`]. Notably a NON-NUMERIC rating
/// key with no hint: today `playback_track_id` falls back to a hash for /// key with no hint: today `playback_track_id` falls back to a hash for
/// those, so case 3 would silently produce a hash and 404. Here it is a /// those, so case 3 would silently produce a hash and 404. Here it is a
@@ -184,9 +219,8 @@ impl PlexSource {
fn cached_rows(item: &MediaRef) -> Vec<PlexCachedTrack> { fn cached_rows(item: &MediaRef) -> Vec<PlexCachedTrack> {
match item.kind() { match item.kind() {
ItemKind::Album => { ItemKind::Album => {
// Moved from `qbz_mixtape::resolve_plex_album_tracks` // The provider-owned replacement for the legacy mixtape and
// (enqueue.rs:366-377) / `local_plex::album_tracks` // `local_plex::album_tracks` cache reads.
// (local_plex.rs:315-321).
qbz_plex::plex_cache_get_album_tracks(item.id().to_string()).unwrap_or_default() qbz_plex::plex_cache_get_album_tracks(item.id().to_string()).unwrap_or_default()
} }
ItemKind::Track => { ItemKind::Track => {
@@ -350,17 +384,13 @@ impl Source for PlexSource {
return Err(Self::not_found(item)); return Err(Self::not_found(item));
} }
self.memoize_art(item, &rows); self.memoize_art(item, &rows);
// ONE mapper for both entry points. `qbz_mixtape:: // ONE mapper for both entry points. `plex_queue_track` is chosen over
// plex_cached_track_to_queue_track` (enqueue.rs:479-508) is chosen over
// `map_cached_to_local_track` + `local_queue_track` // `map_cached_to_local_track` + `local_queue_track`
// (local_plex.rs:276-302 → local_playback.rs:31-108) because it emits // (local_plex.rs:276-302 → local_playback.rs:31-108) because it emits
// the RAW rating key as `QueueTrack.id`. That is survey requirement 2: // the RAW rating key as `QueueTrack.id`. That is survey requirement 2:
// the same Plex track now carries ONE queue id regardless of which view // the same Plex track now carries ONE queue id regardless of which view
// built the queue, instead of the two-way split in §6.2. // built the queue, instead of the two-way split in §6.2.
Ok(rows Ok(rows.iter().map(plex_queue_track).collect())
.iter()
.map(qbz_mixtape::enqueue::plex_cached_track_to_queue_track)
.collect())
} }
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> { async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
@@ -546,6 +576,27 @@ mod tests {
} }
} }
#[test]
fn queue_mapper_preserves_plex_identity_and_quality() {
let mut row = cached_art_row(Some("/disc/thumb"), Some("/collection/thumb"));
row.id = 99;
row.rating_key = "44440".into();
row.bit_depth = Some(24);
row.sample_rate = 192_000;
let queue = plex_queue_track(&row);
assert_eq!(queue.id, 44_440);
assert_eq!(queue.album_id.as_deref(), Some("plex:album"));
assert_eq!(queue.source.as_deref(), Some("plex"));
assert_eq!(queue.artwork_url.as_deref(), Some("/disc/thumb"));
assert_eq!(queue.bit_depth, Some(24));
assert_eq!(queue.sample_rate, Some(192.0));
assert!(queue.hires);
assert!(queue.is_local);
assert!(queue.streamable);
}
#[test] #[test]
fn track_art_prefers_the_disc_then_falls_back_to_the_collection() { fn track_art_prefers_the_disc_then_falls_back_to_the_collection() {
let item = MediaRef::new(SourceId::PLEX, ItemKind::Track, "1"); let item = MediaRef::new(SourceId::PLEX, ItemKind::Track, "1");
@@ -612,7 +663,7 @@ mod tests {
} }
/// BUG 2: the MyQBZ collections path stamps the ALBUM boundary key /// BUG 2: the MyQBZ collections path stamps the ALBUM boundary key
/// (`qbz_plex::plex_album_key` → `plex:<hash>`, enqueue.rs:52-56). It is /// (`qbz_plex::plex_album_key` → `plex:<hash>`). It is
/// not a rating key, so it is IGNORED in favour of the numeric queue id. /// not a rating key, so it is IGNORED in favour of the numeric queue id.
#[test] #[test]
fn plex_prefixed_hint_is_normalised_away_not_passed_through() { fn plex_prefixed_hint_is_normalised_away_not_passed_through() {
@@ -641,8 +692,7 @@ mod tests {
); );
} }
/// A missing hint falls back to the bare numeric id (the MyQBZ shape, /// A missing hint falls back to the bare numeric id (the MyQBZ shape).
/// `enqueue.rs:480`).
#[test] #[test]
fn missing_hint_falls_back_to_queue_id() { fn missing_hint_falls_back_to_queue_id() {
assert_eq!( assert_eq!(
+5 -6
View File
@@ -4,9 +4,9 @@
//! **two** accessors (`local_state.rs:39-61`, open at `:47`; `library_db_qt.rs: //! **two** accessors (`local_state.rs:39-61`, open at `:47`; `library_db_qt.rs:
//! 50-76`, open at `:62`), across 48 call sites in 17 files. Opening one MyQBZ //! 50-76`, open at `:62`), across 48 call sites in 17 files. Opening one MyQBZ
//! collection logged 23–34 `Opening library database` lines — one per item, //! collection logged 23–34 `Opening library database` lines — one per item,
//! because `qbz_mixtape::resolve_collection_tracks`'s `for item in items` loop //! because collection expansion resolves every item independently. Before the
//! (enqueue.rs:50-68) drives `myqbz_play_qt::resolve_local` (:110-115) once per //! registry adapter, that drove one fresh local resolver open per item. A
//! item. A 200-item collection = 200 opens. //! 200-item collection therefore meant 200 opens.
//! //!
//! `open()` is not free: it runs the migrations and the pragmas. //! `open()` is not free: it runs the migrations and the pragmas.
//! //!
@@ -14,9 +14,8 @@
//! //!
//! `LibraryDatabase` wraps a `rusqlite::Connection` //! `LibraryDatabase` wraps a `rusqlite::Connection`
//! (`qbz-library/src/database.rs:32-34`), which is `Send` but `!Sync`. So //! (`qbz-library/src/database.rs:32-34`), which is `Send` but `!Sync`. So
//! `&LibraryDatabase` is `!Send` and must NEVER cross an `.await` — the //! `&LibraryDatabase` is `!Send` and must NEVER cross an `.await`. This type
//! constraint `qbz-mixtape` documents at enqueue.rs:10-15. This type enforces //! enforces that BY API: it hands out no guard, only [`DbPool::with`]. There is no
//! that BY API: it hands out no guard, only [`DbPool::with`]. There is no
//! `checkout() -> Guard` anywhere, public or crate-private. A future author //! `checkout() -> Guard` anywhere, public or crate-private. A future author
//! cannot write the bug. //! cannot write the bug.