refactor(mixtape): move provider resolution to source registry
cx
This commit is contained in:
15 files changed
+118
-331
No files matched your search
Generated
-2
@@ -4375,9 +4375,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"log",
|
||||
"qbz-library",
|
||||
"qbz-models",
|
||||
"qbz-plex",
|
||||
"qbz-qobuz",
|
||||
"rand 0.10.1",
|
||||
"rusqlite",
|
||||
|
||||
@@ -5,23 +5,10 @@ edition = "2021"
|
||||
description = "Mixtapes & Collections backend for QBZ — schema, repo, shuffle, enqueue (headless, frontend-agnostic)"
|
||||
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]
|
||||
# Shared QBZ crates (the real models + resolvers, not src-tauri shims)
|
||||
# Shared QBZ crates
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
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
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
@@ -6,13 +6,9 @@
|
||||
//!
|
||||
//! Frontend-agnostic notes (ADR-006):
|
||||
//! - The Qobuz resolvers are async free fns over `qbz_qobuz::QobuzClient`.
|
||||
//! - The local/Plex resolvers are SYNCHRONOUS free fns over
|
||||
//! `qbz_library::LibraryDatabase` / the `qbz-plex` cache. `&LibraryDatabase`
|
||||
//! wraps a `rusqlite::Connection`, which is `!Sync`, so a `&LibraryDatabase`
|
||||
//! 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.
|
||||
//! - Provider-specific local and LAN resolution belongs to `qbz-source`.
|
||||
//! This crate only defines collection ordering/flattening and the generic
|
||||
//! resolver seam, so it does not pull provider databases into its graph.
|
||||
|
||||
use qbz_models::mixtape::{AlbumSource, CollectionPlayMode, ItemType, MixtapeCollectionItem};
|
||||
use qbz_models::QueueTrack as CoreQueueTrack;
|
||||
@@ -21,8 +17,8 @@ use qbz_models::QueueTrack as CoreQueueTrack;
|
||||
use qbz_models::Track as ApiTrack;
|
||||
|
||||
/// Trait for expanding a single Mixtape item into its tracks. Implementations:
|
||||
/// - `ProdItemResolver` — uses the Qobuz client + a caller-supplied local
|
||||
/// resolver (the real production path)
|
||||
/// - `ProdItemResolver` — uses the Qobuz client plus a caller-supplied resolver
|
||||
/// for non-Qobuz items
|
||||
/// - mocks in `#[cfg(test)]`
|
||||
#[async_trait::async_trait]
|
||||
pub trait ItemResolver: Send + Sync {
|
||||
@@ -153,16 +149,9 @@ pub fn previous_item_index(
|
||||
|
||||
// ──────────────────────────── ProdItemResolver ────────────────────────────
|
||||
|
||||
/// Production resolver. Holds a reference to the shared Qobuz client and a
|
||||
/// caller-supplied `local` closure that resolves local/Plex items
|
||||
/// synchronously.
|
||||
///
|
||||
/// `&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.
|
||||
/// Resolver over the shared Qobuz client plus a caller-supplied synchronous
|
||||
/// closure for non-Qobuz items. The closure keeps provider state and database
|
||||
/// handles outside this generic crate.
|
||||
pub struct ProdItemResolver<'a, L>
|
||||
where
|
||||
L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync,
|
||||
@@ -175,9 +164,8 @@ impl<'a, L> ProdItemResolver<'a, L>
|
||||
where
|
||||
L: Fn(&MixtapeCollectionItem) -> Result<Vec<CoreQueueTrack>, String> + Send + Sync,
|
||||
{
|
||||
/// Build a production resolver from the shared Qobuz client and a local
|
||||
/// resolver closure. The closure is invoked only for `AlbumSource::Local`
|
||||
/// items and must perform its DB access synchronously.
|
||||
/// Build a resolver from the shared Qobuz client and a non-Qobuz resolver
|
||||
/// closure. The closure is invoked only for `AlbumSource::Local` items.
|
||||
pub fn new(client: &'a qbz_qobuz::QobuzClient, local: L) -> Self {
|
||||
Self { client, local }
|
||||
}
|
||||
@@ -207,8 +195,7 @@ where
|
||||
.map_err(|_| format!("invalid qobuz playlist id: {}", item.source_item_id))?;
|
||||
resolve_qobuz_playlist(self.client, playlist_id).await
|
||||
}
|
||||
// All local/Plex resolution is delegated to the caller-supplied
|
||||
// synchronous closure (no `&LibraryDatabase` held across `.await`).
|
||||
// Provider resolution is delegated to the caller-supplied closure.
|
||||
(_, 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())
|
||||
}
|
||||
|
||||
// ── Local album (synchronous, frontend-agnostic) ──
|
||||
|
||||
/// 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 ──
|
||||
// ── Qobuz mapping helper ──
|
||||
|
||||
/// Map a Qobuz API `Track` to a `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 ────────────────────────────
|
||||
|
||||
#[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]
|
||||
async fn resolver_stamps_hint_and_flattens_in_order() {
|
||||
let items = vec![
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
//! Mixtapes & Collections backend — schema, repository, shuffle/DJ-mix, and
|
||||
//! enqueue/resolution logic for QBZ.
|
||||
//!
|
||||
//! Extracted verbatim from `src-tauri/src/mixtape/*` and re-typed against the
|
||||
//! real shared crates (`qbz-models`, `qbz-qobuz`, `qbz-library`, `qbz-plex`),
|
||||
//! so it runs headless with no Tauri state and no `#[tauri::command]` wrappers
|
||||
//! (ADR-005: no legacy wrappers; ADR-006: frontend-agnostic core).
|
||||
//! Runs headless over shared models and Qobuz APIs, while provider-specific
|
||||
//! local/LAN resolution remains in `qbz-source` (ADR-006).
|
||||
//!
|
||||
//! - [`schema`] — SQLite migrations (`run_mixtape_migrations`).
|
||||
//! - [`repo`] — CRUD over `&Connection` / `&mut Connection`.
|
||||
//! - [`shuffle`] — pure DJ-mix sampler (`rand`, `strsim`).
|
||||
//! - [`enqueue`] — `ItemResolver` trait, `resolve_collection_tracks`,
|
||||
//! `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 repo;
|
||||
pub mod schema;
|
||||
pub mod shuffle;
|
||||
|
||||
// Convenience re-exports for callers (the Slint app in a later slice).
|
||||
// Convenience re-exports for application callers.
|
||||
pub use enqueue::{
|
||||
next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver,
|
||||
ProdItemResolver,
|
||||
next_item_index, previous_item_index, resolve_collection_tracks, ItemResolver, ProdItemResolver,
|
||||
};
|
||||
pub use schema::run_mixtape_migrations;
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! All functions take `&Connection` (or `&mut Connection` when a transaction
|
||||
//! 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 uuid::Uuid;
|
||||
|
||||
@@ -214,12 +214,10 @@ fn is_ephemeral_item(it: &AddItem) -> bool {
|
||||
/// (`qbz-models/src/mixtape.rs` — `AlbumSource::Local` is an umbrella), so
|
||||
/// the source is NOT consulted for an album.
|
||||
/// - **Mixtape**: albums, playlists and tracks.
|
||||
/// - **A LOCAL playlist belongs nowhere.** `qbz_mixtape::enqueue::
|
||||
/// resolve_local_item` (`enqueue.rs:404,417-422`) answers `(Playlist, Local)`
|
||||
/// with the hard error `"local playlists not supported in this release"` —
|
||||
/// there is no local-only playlist id to resolve against. That error is THE
|
||||
/// 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.
|
||||
/// - **A LOCAL playlist belongs nowhere.** The local and LAN implementations
|
||||
/// in `qbz-source` reject playlist resolution; there is no local-only
|
||||
/// playlist id to resolve against. That error is the unsupported combination
|
||||
/// R2 talks about, and this arm makes it unreachable from the UI.
|
||||
/// - **Ephemeral: nothing** (R3).
|
||||
///
|
||||
/// 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")]),
|
||||
Accepts::MIXTAPE_ONLY
|
||||
);
|
||||
// R2: `qbz_mixtape::enqueue::resolve_local_item` hard-errors on
|
||||
// (Playlist, Local). Offering ANY container for it is what makes that
|
||||
// error reachable, so nothing is offered and the picker never opens.
|
||||
// R2: qbz-source rejects (Playlist, Local). Offering ANY container for
|
||||
// it would make that error reachable, so the picker never opens.
|
||||
let local_playlist = payload_accepts(&[add("playlist", "local", "whatever")]);
|
||||
assert_eq!(local_playlist, Accepts::NONE);
|
||||
assert!(!local_playlist.any());
|
||||
|
||||
@@ -26,8 +26,8 @@ qbz-media-cache = { path = "../qbz-media-cache" }
|
||||
# — every statement lives in `qbz-media-cache` or `qbz-library`.
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
# `ItemResolver` — this crate IMPLEMENTS it (mixtape_adapter.rs). qbz-mixtape is
|
||||
# NOT modified and NOT absorbed; the dependency remains one-directional so the
|
||||
# Qt application can consume the adapter without coupling qbz-mixtape to it.
|
||||
# provider-agnostic and the dependency remains one-directional, so the Qt
|
||||
# application can consume the adapter without coupling qbz-mixtape to sources.
|
||||
qbz-mixtape = { path = "../qbz-mixtape" }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -110,14 +110,10 @@ pub enum Accepted {
|
||||
Release,
|
||||
/// A playlist — and only one this build can actually resolve.
|
||||
///
|
||||
/// `qbz_mixtape::enqueue::resolve_local_item` answers `(Playlist, Local)`
|
||||
/// with the hard error `"local playlists not supported in this release"`
|
||||
/// (enqueue.rs:418-424: the schema has no local-only playlist id to
|
||||
/// resolve against), and neither [`crate::LocalSource`] nor
|
||||
/// [`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.
|
||||
/// Neither [`crate::LocalSource`] nor [`crate::PlexSource`] claims a
|
||||
/// playlist: both answer [`crate::SourceError::Unsupported`]. That error
|
||||
/// is the unsupported combination R2 talks about; it stays as the safety
|
||||
/// net, and this slot is what makes it unreachable from the UI.
|
||||
Playlist,
|
||||
/// A single track.
|
||||
Track,
|
||||
|
||||
@@ -57,8 +57,8 @@ pub enum SourceError {
|
||||
id: String,
|
||||
},
|
||||
|
||||
/// This source does not resolve that kind. Carries `qbz-mixtape`'s "local
|
||||
/// playlists not supported in this release" contract (enqueue.rs:418-423).
|
||||
/// This source does not resolve that kind. In particular, local and LAN
|
||||
/// sources do not expose playlist resolution.
|
||||
#[error("{by}: {kind:?} is not supported")]
|
||||
Unsupported { by: SourceId, kind: ItemKind },
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//!
|
||||
//! | 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 |
|
||||
//! | 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 |
|
||||
@@ -56,7 +56,8 @@
|
||||
//!
|
||||
//! # 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.
|
||||
//! - **Nothing long-lived is cached that another crate owns.** The Qobuz client
|
||||
//! is READ THROUGH a [`ClientLens`] on every call, because `qbz-core`
|
||||
|
||||
@@ -44,7 +44,7 @@ impl QualityHint {
|
||||
///
|
||||
/// Moved from the kHz normalisation duplicated at
|
||||
/// `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 {
|
||||
let khz = if sample_rate_hz >= 1000.0 {
|
||||
sample_rate_hz / 1000.0
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
//! Adapts the registry to `qbz_mixtape::ItemResolver`.
|
||||
//!
|
||||
//! `qbz-mixtape` is NOT modified, NOT absorbed, and stays a leaf of the graph:
|
||||
//! this crate depends on it, never the reverse. `ItemResolver`,
|
||||
//! `ProdItemResolver`, `resolve_collection_tracks`, `shuffle_items`,
|
||||
//! `next_item_index` and every free resolver fn stay byte-identical, so the
|
||||
//! `qbz` (Slint) crate keeps compiling and behaving exactly as today with zero
|
||||
//! edits.
|
||||
//! `qbz-mixtape` stays a provider-agnostic leaf of the graph: this crate
|
||||
//! depends on it, never the reverse. It retains `ItemResolver`, generic
|
||||
//! collection expansion and Qobuz mapping; this adapter supplies local and LAN
|
||||
//! dispatch through the registry.
|
||||
|
||||
use qbz_models::mixtape::MixtapeCollectionItem;
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
@@ -568,9 +568,8 @@ impl Source for LocalSource {
|
||||
}
|
||||
ItemKind::Track => Self::row_id(raw)
|
||||
.map(|row| MediaRef::new(SourceId::LOCAL, ItemKind::Track, row.to_string())),
|
||||
// enqueue.rs:418-423 — the library schema stores qobuz_playlist_id
|
||||
// + local_track_id rows but there is no unique "local-only playlist
|
||||
// id" to resolve against.
|
||||
// The library schema stores qobuz_playlist_id + local_track_id rows
|
||||
// but there is no unique local-only playlist id to resolve against.
|
||||
ItemKind::Playlist => Err(SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: ItemKind::Playlist,
|
||||
@@ -586,19 +585,18 @@ impl Source for LocalSource {
|
||||
|
||||
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
|
||||
// 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
|
||||
// does today (local_playback.rs:302, :313, :323).
|
||||
let rows: Vec<LocalTrack> = match item.kind() {
|
||||
// Moved from `qbz_mixtape::resolve_local_album_tracks`
|
||||
// (enqueue.rs:351-364) + `local_albums::fetch_album_tracks_blocking`
|
||||
// (local_albums.rs:225-241).
|
||||
// Provider-owned replacement for the legacy mixtape resolver and
|
||||
// `local_albums::fetch_album_tracks_blocking`.
|
||||
ItemKind::Album => self.album_tracks(item.id()),
|
||||
// Moved from `local_playback::play_folder` (local_playback.rs:312-319).
|
||||
ItemKind::Folder => self
|
||||
.with(|db| db.list_folder_tracks_recursive(item.id(), false))
|
||||
.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 => {
|
||||
let row_id = item.id().parse::<i64>().map_err(|_| SourceError::BadIdShape {
|
||||
by: SourceId::LOCAL,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! | path | `QueueTrack.id` | `source_item_id_hint` |
|
||||
//! |---|---|---|
|
||||
//! | 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,
|
||||
//! 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
|
||||
/// `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;
|
||||
|
||||
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP` (artwork_qt.rs:82).
|
||||
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.
|
||||
///
|
||||
/// 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);
|
||||
/// 2. a numeric id with [`PLEX_TRACK_ID_FLOOR`] set → mask the namespace
|
||||
/// bit and RESOLVE that cache row id, never `id.to_string()`;
|
||||
/// 3. a bare numeric id → the rating key (the MyQBZ path,
|
||||
/// `enqueue.rs:480` `rating_key.parse().unwrap_or(track.id)`);
|
||||
/// 3. a bare numeric id → the rating key (the MyQBZ path built by
|
||||
/// [`plex_queue_track`]);
|
||||
/// 4. otherwise → [`SourceError::BadIdShape`]. Notably a NON-NUMERIC rating
|
||||
/// 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
|
||||
@@ -184,9 +219,8 @@ impl PlexSource {
|
||||
fn cached_rows(item: &MediaRef) -> Vec<PlexCachedTrack> {
|
||||
match item.kind() {
|
||||
ItemKind::Album => {
|
||||
// Moved from `qbz_mixtape::resolve_plex_album_tracks`
|
||||
// (enqueue.rs:366-377) / `local_plex::album_tracks`
|
||||
// (local_plex.rs:315-321).
|
||||
// The provider-owned replacement for the legacy mixtape and
|
||||
// `local_plex::album_tracks` cache reads.
|
||||
qbz_plex::plex_cache_get_album_tracks(item.id().to_string()).unwrap_or_default()
|
||||
}
|
||||
ItemKind::Track => {
|
||||
@@ -350,17 +384,13 @@ impl Source for PlexSource {
|
||||
return Err(Self::not_found(item));
|
||||
}
|
||||
self.memoize_art(item, &rows);
|
||||
// ONE mapper for both entry points. `qbz_mixtape::
|
||||
// plex_cached_track_to_queue_track` (enqueue.rs:479-508) is chosen over
|
||||
// ONE mapper for both entry points. `plex_queue_track` is chosen over
|
||||
// `map_cached_to_local_track` + `local_queue_track`
|
||||
// (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 same Plex track now carries ONE queue id regardless of which view
|
||||
// built the queue, instead of the two-way split in §6.2.
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(qbz_mixtape::enqueue::plex_cached_track_to_queue_track)
|
||||
.collect())
|
||||
Ok(rows.iter().map(plex_queue_track).collect())
|
||||
}
|
||||
|
||||
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]
|
||||
fn track_art_prefers_the_disc_then_falls_back_to_the_collection() {
|
||||
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
|
||||
/// (`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.
|
||||
#[test]
|
||||
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,
|
||||
/// `enqueue.rs:480`).
|
||||
/// A missing hint falls back to the bare numeric id (the MyQBZ shape).
|
||||
#[test]
|
||||
fn missing_hint_falls_back_to_queue_id() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! **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
|
||||
//! collection logged 23–34 `Opening library database` lines — one per item,
|
||||
//! because `qbz_mixtape::resolve_collection_tracks`'s `for item in items` loop
|
||||
//! (enqueue.rs:50-68) drives `myqbz_play_qt::resolve_local` (:110-115) once per
|
||||
//! item. A 200-item collection = 200 opens.
|
||||
//! because collection expansion resolves every item independently. Before the
|
||||
//! registry adapter, that drove one fresh local resolver open per item. A
|
||||
//! 200-item collection therefore meant 200 opens.
|
||||
//!
|
||||
//! `open()` is not free: it runs the migrations and the pragmas.
|
||||
//!
|
||||
@@ -14,9 +14,8 @@
|
||||
//!
|
||||
//! `LibraryDatabase` wraps a `rusqlite::Connection`
|
||||
//! (`qbz-library/src/database.rs:32-34`), which is `Send` but `!Sync`. So
|
||||
//! `&LibraryDatabase` is `!Send` and must NEVER cross an `.await` — the
|
||||
//! constraint `qbz-mixtape` documents at enqueue.rs:10-15. This type enforces
|
||||
//! that BY API: it hands out no guard, only [`DbPool::with`]. There is no
|
||||
//! `&LibraryDatabase` is `!Send` and must NEVER cross an `.await`. This type
|
||||
//! enforces that BY API: it hands out no guard, only [`DbPool::with`]. There is no
|
||||
//! `checkout() -> Guard` anywhere, public or crate-private. A future author
|
||||
//! cannot write the bug.
|
||||
|
||||
|
||||
Reference in new issue
Block a user