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 = [
|
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",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user