feat(source): add qbz-source, the source-agnostic media seam
Stage 1 of the seam the owner asked for: "son vistas súper híbridas donde se mezcla Qobuz/Local(Folders/Plex) ... hay que hacer una solución agnóstica porque no tardamos en meter a Jellyfin y quizás Navidrome". Nothing is wired yet — this lands the crate, its three source implementations and its tests. `qbz-qt` and `qbz` are untouched, so this commit changes no behaviour. The problem it exists to end, measured rather than assumed: 129 source-branching sites across 30 files in qbz-qt alone, SEVEN distinct item->tracks resolvers none of which can reach the others, FIVE non-interchangeable artwork pipelines over one correct taxonomy, and six incompatible source vocabularies — three of which cannot express Plex at all, including the PERSISTED one (`AlbumSource` is `Qobuz|Local`). Every hybrid view re-wires the per-source handling by hand, which is why something is always missing. One `Source` trait covering the four concerns together — tracks, metadata and origin, artwork, playback — because splitting them is exactly what lets a view forget one. A `SourceRegistry` with claim-priority ordering (Qobuz, Plex, then Local; Plex-before-Local is load-bearing). `RegistryResolver` implements `qbz_mixtape::ItemResolver`, so that crate and the Slint glue stay byte- untouched and keep compiling. The three measured bugs die structurally, not by discipline: - The local DB was reopened PER CALL, so a My QBZ collection logged one "Opening library database" per item (34 measured live). `LocalSource` owns a pooled handle, and it hands out no guard — only `with(|db| ...)` — so a `!Send` `&LibraryDatabase` provably cannot cross an await. `qbz-mixtape`'s sync-resolver constraint becomes an API property instead of a comment. - A `plex:<hash>` album key reached an API that wants a rating key. `MediaRef` has no public constructor: only `registry.claim` mints one, and `PlexSource::claim` rejects that shape in track position. The survey also found a second, undiscovered face of the same bug — "Go to album" on a Plex track row opens an empty page. - Local rows in a hybrid list rendered placeholder discs. Traced to its real mechanism: those items are stored with `artwork_url: None` and the only backfill is behind a Qobuz-client gate, so offline or pre-login the art is on disk and never shown. `Source::artwork` is one call per row whatever the source, and keeps the cheap/cold split the local path already had. Adding Jellyfin is one new file, four diff lines inside this crate, and one line in `auth_qt.rs` — and the design states this only holds BECAUSE artwork sits on the trait. 36 tests. The id tests derive their fixtures from `PLEX_TRACK_ID_FLOOR` instead of transcribing them, which is not pedantry: the survey's own id table — the artefact meant to be the definitive reference — paired a namespaced id with the wrong rating key, and the transcribed test caught it on the first run. Design and adversarial review (6 BLOCKER / 10 MAJOR / 7 MINOR, all corrected): qbz-nix-docs/qt-frontend/2026-07-30-source-seam/. cc
This commit is contained in:
17 files changed
+3766
No files matched your search
Generated
+14
@@ -6700,6 +6700,20 @@ dependencies = [
|
||||
"slint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-source"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"log",
|
||||
"qbz-library",
|
||||
"qbz-mixtape",
|
||||
"qbz-models",
|
||||
"qbz-plex",
|
||||
"qbz-qobuz",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-text-utils"
|
||||
version = "2.0.2"
|
||||
|
||||
@@ -40,6 +40,10 @@ members = [
|
||||
"qbz-log",
|
||||
"qbzd",
|
||||
"qbz-qt",
|
||||
# The source-agnostic seam (one trait + one registry over Qobuz / local /
|
||||
# Plex). Purely additive: nothing in `qbz` or `qbz-mixtape` changes, and it
|
||||
# deliberately does NOT depend on qbz-audio / qbz-player.
|
||||
"qbz-source",
|
||||
]
|
||||
|
||||
# Qt-frontend POC worktree: plain `cargo build`/`cargo check` in this workspace
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "qbz-source"
|
||||
description = "The source-agnostic seam: one trait + one registry over Qobuz, local files and Plex"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# QueueTrack, mixtape::*, plex_thumb_url. No internal deps of its own.
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
# QobuzClient (Clone) — the catalog arm.
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
# LibraryDatabase / LocalTrack / thumbnails — the local arm.
|
||||
qbz-library = { path = "../qbz-library" }
|
||||
# Plex cache reads + plex_resolve_track_media — the Plex arm.
|
||||
qbz-plex = { path = "../qbz-plex" }
|
||||
# `ItemResolver` — this crate IMPLEMENTS it (mixtape_adapter.rs). qbz-mixtape is
|
||||
# NOT modified and NOT absorbed; the dependency is one-directional so the `qbz`
|
||||
# (Slint) crate keeps compiling untouched.
|
||||
qbz-mixtape = { path = "../qbz-mixtape" }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# ABSENT ON PURPOSE (design §8): qbz-audio, qbz-player (the PROTECTED backend is
|
||||
# not linked, so it cannot be reached even by accident), qbz-core, qbz-app,
|
||||
# tokio (this crate never spawns), reqwest, qbz-i18n, qbz-cache, rusqlite.
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Artwork as DATA a source PRODUCES, instead of a string a caller sniffs.
|
||||
//!
|
||||
//! [`ArtRef`] generalises `artwork_qt::ArtUrl` (artwork_qt.rs:124-142) with one
|
||||
//! change that is the whole of bug 3: it is produced by the source instead of
|
||||
//! classified from a string by the caller. `artwork_qt::classify`
|
||||
//! (artwork_qt.rs:147-180) is DELETED when its last caller moves — it only ever
|
||||
//! existed because the string had already lost its provenance by the time a
|
||||
//! view got it.
|
||||
//!
|
||||
//! A hybrid list then does ONE thing per row, identical for a Qobuz album, a
|
||||
//! local folder and a Plex row:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! match registry.artwork(&item_ref, ArtSize::Row) {
|
||||
//! ArtRef::File(p) => row.art_path = file_url(&p),
|
||||
//! ArtRef::Fetch { url, cache_key } => misses.push((cache_key, url)),
|
||||
//! ArtRef::None | ArtRef::Unavailable(_) => {}
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The drawn size class. The source picks the cheapest representation that
|
||||
/// satisfies it; it is NOT a pixel promise.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ArtSize {
|
||||
/// ~50 px — MyQBZ / queue / now-playing rows.
|
||||
Row,
|
||||
/// ~256 px — grid cards.
|
||||
///
|
||||
/// Matches `artwork_qt::PLEX_THUMB_PX` (artwork_qt.rs:78), which is
|
||||
/// deliberately ONE app-wide Plex transcode size so a cover is downloaded
|
||||
/// once. Do not add a second Plex size.
|
||||
Card,
|
||||
/// Full — hero headers, immersive.
|
||||
Full,
|
||||
}
|
||||
|
||||
impl ArtSize {
|
||||
/// The Plex server-side transcode edge in px, or `None` for "raw
|
||||
/// full-res". One size app-wide (see [`ArtSize::Card`]).
|
||||
pub fn plex_px(self) -> Option<u32> {
|
||||
match self {
|
||||
ArtSize::Row | ArtSize::Card => Some(PLEX_THUMB_PX),
|
||||
ArtSize::Full => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Qobuz CDN `_<size>.jpg` token this class rewrites to. Mirrors the
|
||||
/// per-cell targets `myqbz_qt::small_qobuz_url` is called with
|
||||
/// (myqbz_qt.rs:404-407: `<=80 -> 50`, `<=200 -> 150`, else 300).
|
||||
pub fn qobuz_px(self) -> u32 {
|
||||
match self {
|
||||
ArtSize::Row => 50,
|
||||
ArtSize::Card => 300,
|
||||
ArtSize::Full => 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side transcode size for EVERY Plex thumb this process fetches.
|
||||
/// Moved verbatim from `artwork_qt::PLEX_THUMB_PX` (artwork_qt.rs:78): one size
|
||||
/// app-wide is deliberate, because the cache key is the final tokenized url and
|
||||
/// a second size would mean a second download of the same cover.
|
||||
pub const PLEX_THUMB_PX: u32 = 256;
|
||||
|
||||
/// What a source says its art IS.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArtRef {
|
||||
/// Nothing to show.
|
||||
None,
|
||||
/// ALREADY on the local filesystem — a raw path, no `file://`, never
|
||||
/// fetched. Local Library covers, `qbz-library` thumbnails and
|
||||
/// offline-download covers.
|
||||
File(PathBuf),
|
||||
/// Fetchable http(s).
|
||||
///
|
||||
/// `cache_key` is the STABLE key the caller memoizes under; `url` is what
|
||||
/// it GETs. They differ for Plex, whose tokenized url is rebuilt every pass
|
||||
/// while the `/library/...` path is stable — exactly the split
|
||||
/// `artwork_qt::disk_path(key, fetch)` already relies on
|
||||
/// (artwork_qt.rs:231-238).
|
||||
Fetch { url: String, cache_key: String },
|
||||
/// The art exists but cannot be resolved right now (Plex not connected, a
|
||||
/// Qobuz cover not fetched yet). Distinct from [`ArtRef::None`] so the miss
|
||||
/// is logged for what it is — `ArtUrl::PlexUnconfigured`'s reason for
|
||||
/// existing (artwork_qt.rs:139-141).
|
||||
Unavailable(&'static str),
|
||||
}
|
||||
|
||||
impl Default for ArtRef {
|
||||
fn default() -> Self {
|
||||
ArtRef::None
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtRef {
|
||||
/// True when there is nothing a caller can render or fetch.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self, ArtRef::None | ArtRef::Unavailable(_))
|
||||
}
|
||||
|
||||
/// A raw filesystem path, when the art is already on disk.
|
||||
pub fn as_path(&self) -> Option<&std::path::Path> {
|
||||
match self {
|
||||
ArtRef::File(p) => Some(p.as_path()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The one error vocabulary for the seam.
|
||||
//!
|
||||
//! Note the field is `by`, never `source`: `thiserror` (workspace pins `= "2"`,
|
||||
//! `crates/Cargo.toml:75`) treats a variant field NAMED `source` as the
|
||||
//! `std::error::Error::source()` chain and requires it to implement `Error`.
|
||||
//! [`crate::SourceId`] is a `&'static str` newtype and does not, so a field
|
||||
//! called `source` here would not compile.
|
||||
|
||||
use crate::id::{ItemKind, RawRef, SourceId};
|
||||
|
||||
/// Everything that can go wrong between "a caller has a string" and "the
|
||||
/// frontend has tracks, metadata, artwork or a playback ticket".
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SourceError {
|
||||
/// No source recognised the reference at all.
|
||||
#[error("no source claims {0:?}")]
|
||||
Unclaimed(RawRef),
|
||||
|
||||
/// A bare id that MORE THAN ONE source could legitimately own, with no
|
||||
/// source word to break the tie (survey §6.3: a numeric id can be a Qobuz
|
||||
/// track id, `local_tracks.id = 2954`, OR a Plex rating key `44440`).
|
||||
///
|
||||
/// The registry REFUSES to guess. This is deliberately loud: it turns the
|
||||
/// producer-side gaps (`HomeCard` has no `source` field — survey IC-7; the
|
||||
/// eight QML sites that hardcode `"source": "qobuz"` — survey §2.6) into
|
||||
/// visible failures instead of silently mis-rendered rows.
|
||||
#[error("ambiguous id {id:?} ({kind:?}): candidates {candidates:?}; caller must supply a source")]
|
||||
Ambiguous {
|
||||
id: String,
|
||||
kind: Option<ItemKind>,
|
||||
candidates: Vec<SourceId>,
|
||||
},
|
||||
|
||||
/// The named home for what bug 2 used to be. A source that RECOGNISES a
|
||||
/// shape but rejects it in this position says so LOUDLY here, instead of
|
||||
/// forwarding it to an API that answers 404 two layers down.
|
||||
#[error("{by}: id shape rejected: {id} ({why})")]
|
||||
BadIdShape {
|
||||
by: SourceId,
|
||||
id: String,
|
||||
why: &'static str,
|
||||
},
|
||||
|
||||
/// The source exists but has nothing to work with (no Qobuz client, no
|
||||
/// Plex credentials, no bound user directory).
|
||||
#[error("{by}: not configured ({why})")]
|
||||
NotConfigured { by: SourceId, why: &'static str },
|
||||
|
||||
/// The reference is well-formed and owned, and the thing is not there.
|
||||
/// A zero-track resolve is this, with the item's id — there is deliberately
|
||||
/// no separate `Empty` variant (two variants for one condition is how the
|
||||
/// six vocabularies in survey §1 multiplied in the first place).
|
||||
#[error("{by}: {kind:?} {id} not found")]
|
||||
NotFound {
|
||||
by: SourceId,
|
||||
kind: ItemKind,
|
||||
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).
|
||||
#[error("{by}: {kind:?} is not supported")]
|
||||
Unsupported { by: SourceId, kind: ItemKind },
|
||||
|
||||
/// Anything the underlying crate reported as a string.
|
||||
#[error("{by}: {msg}")]
|
||||
Backend { by: SourceId, msg: String },
|
||||
}
|
||||
|
||||
impl SourceError {
|
||||
/// Which source produced this error, when one did.
|
||||
pub fn by(&self) -> Option<SourceId> {
|
||||
match self {
|
||||
SourceError::Unclaimed(_) | SourceError::Ambiguous { .. } => None,
|
||||
SourceError::BadIdShape { by, .. }
|
||||
| SourceError::NotConfigured { by, .. }
|
||||
| SourceError::NotFound { by, .. }
|
||||
| SourceError::Unsupported { by, .. }
|
||||
| SourceError::Backend { by, .. } => Some(*by),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Identity: who owns an id, and what the canonical form of that id is.
|
||||
//!
|
||||
//! This module is the whole of the seam's identity half. Two types matter:
|
||||
//!
|
||||
//! - [`RawRef`] — what a CALLER has: strings of unknown provenance. It is the
|
||||
//! only reference a frontend may construct, and it is deliberately dumb.
|
||||
//! - [`MediaRef`] — a NORMALISED, source-owned reference. It has **no public
|
||||
//! constructor**: the only way to obtain one outside this crate is
|
||||
//! [`crate::SourceRegistry::claim`], i.e. from the source that owns the id
|
||||
//! shape. That is bug 2's structural fix on the identity side — a caller
|
||||
//! cannot mint `MediaRef { PLEX, Track, "plex:deadbeef" }` because it cannot
|
||||
//! mint a `MediaRef` at all.
|
||||
|
||||
use qbz_models::mixtape::{AlbumSource, ItemType, MixtapeCollectionItem};
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::meta::SourceBadge;
|
||||
|
||||
/// A source's stable wire name.
|
||||
///
|
||||
/// These strings ARE the values already carried by `QueueTrack.source` /
|
||||
/// `LocalTrack.source`, so nothing on disk or in the queue has to be migrated.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct SourceId(&'static str);
|
||||
|
||||
impl SourceId {
|
||||
/// The Qobuz streaming catalog.
|
||||
pub const QOBUZ: SourceId = SourceId("qobuz");
|
||||
/// A Plex Media Server (through the local Plex cache).
|
||||
pub const PLEX: SourceId = SourceId("plex");
|
||||
/// Files on this machine: the indexed library, Qobuz downloads/purchases
|
||||
/// and the session-scoped ephemeral folder.
|
||||
pub const LOCAL: SourceId = SourceId("local");
|
||||
|
||||
/// The wire name, e.g. `"qobuz"`.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// The ONE mapping from a source WORD to a source.
|
||||
///
|
||||
/// Six vocabularies exist on disk and in the queue (survey §1); this table
|
||||
/// is the single place that reconciles them:
|
||||
///
|
||||
/// | word | where | → |
|
||||
/// |---|---|---|
|
||||
/// | `"qobuz"` | `QueueTrack.source`, `AlbumSource::Qobuz` | `QOBUZ` |
|
||||
/// | `""` / absent | `local_tracks.source`, `HomeCard` (no field at all) | `None` |
|
||||
/// | `"local"`, `"user"` | `QueueTrack.source`, `LocalTrack.source` | `LOCAL` |
|
||||
/// | `"qobuz_download"`, `"qobuz_purchase"`, `"offline"` | `LocalTrack.source`, badges | `LOCAL` (an offline copy is a FILE; the distinction is a [`SourceBadge`]) |
|
||||
/// | `"ephemeral"` | `lyrics_qt.rs:816`, `foryou_qt.rs:775` | `LOCAL` (session-scoped arm inside `LocalSource`) |
|
||||
/// | `"plex"` | `QueueTrack.source`, `local_favorites.db` CHECK | `PLEX` |
|
||||
///
|
||||
/// Unknown words return `None`. They are never guessed into `QOBUZ`, which
|
||||
/// is `myqbz_add_qt::source_from_str`'s bug (survey IC-6).
|
||||
pub fn from_word(w: &str) -> Option<SourceId> {
|
||||
match w.trim().to_ascii_lowercase().as_str() {
|
||||
"qobuz" => Some(SourceId::QOBUZ),
|
||||
"plex" => Some(SourceId::PLEX),
|
||||
"local" | "user" | "qobuz_download" | "qobuz_purchase" | "offline" | "ephemeral" => {
|
||||
Some(SourceId::LOCAL)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SourceId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What KIND of thing an id names.
|
||||
///
|
||||
/// `Artist` and `Folder` exist because two current callers route by them
|
||||
/// (`library_qt.rs:1037` favourites kind `"artist"`; `local_playback::enqueue`
|
||||
/// kind `"folder"`). Neither is resolved to tracks by every source — the
|
||||
/// `Unsupported` error is the honest answer where it is not.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum ItemKind {
|
||||
Album,
|
||||
Track,
|
||||
Playlist,
|
||||
Artist,
|
||||
Folder,
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
/// The untranslated kind key a view renders. i18n stays in the frontend —
|
||||
/// this crate never links `qbz-i18n`.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ItemKind::Album => "album",
|
||||
ItemKind::Track => "track",
|
||||
ItemKind::Playlist => "playlist",
|
||||
ItemKind::Artist => "artist",
|
||||
ItemKind::Folder => "folder",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map `qbz_models::mixtape::ItemType` (the persisted mixtape vocabulary).
|
||||
pub fn from_item_type(t: ItemType) -> ItemKind {
|
||||
match t {
|
||||
ItemType::Album => ItemKind::Album,
|
||||
ItemType::Track => ItemKind::Track,
|
||||
ItemType::Playlist => ItemKind::Playlist,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a CALLER has: strings of unknown provenance.
|
||||
///
|
||||
/// This is the only reference a frontend constructs. Every field is a fact the
|
||||
/// caller genuinely holds; nothing here is a guess, and nothing here is
|
||||
/// interpreted by the caller.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RawRef {
|
||||
/// The caller's source word, NORMALISED through [`SourceId::from_word`].
|
||||
/// `None` = the caller genuinely does not know.
|
||||
pub source: Option<SourceId>,
|
||||
/// The badge the source word implies. Kept separately from `source`
|
||||
/// because `"qobuz_download"` and `"local"` are the SAME source and a
|
||||
/// DIFFERENT badge — and because `LocalSource` needs the distinction to
|
||||
/// pick the right id out of an offline row (see `local_playback.rs:85-90`,
|
||||
/// where an offline queue track carries the Qobuz track id in `id` and the
|
||||
/// local row id in `source_item_id_hint`).
|
||||
pub badge: SourceBadge,
|
||||
/// What the caller believes it is naming. `None` = unknown; each source
|
||||
/// then applies its own default (Plex: `plex:` prefix ⇒ Album, else Track).
|
||||
pub kind: Option<ItemKind>,
|
||||
/// The id as the caller has it. May be a Qobuz id, a local group key, a
|
||||
/// `plex:<hash>` album key, a namespaced numeric row id, an absolute path…
|
||||
pub id: String,
|
||||
/// `QueueTrack.is_local`, when the caller holds a live `QueueTrack`, or
|
||||
/// `AlbumSource::Local` when it holds a mixtape item. This is the ONLY
|
||||
/// disambiguator for a bare numeric id whose `source` is `None` —
|
||||
/// `resolve_bulk_qobuz_track_ids` (myqbz_play_qt.rs:246-248) already uses
|
||||
/// exactly this rule and it is the only correct one.
|
||||
pub is_local: Option<bool>,
|
||||
/// `QueueTrack.source_item_id_hint` verbatim. NEVER interpreted by a
|
||||
/// caller — only by the source that owns the shape.
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl RawRef {
|
||||
/// Build a reference from a source WORD (normalised through
|
||||
/// [`SourceId::from_word`]), a kind and an id.
|
||||
pub fn new(source: &str, kind: ItemKind, id: &str) -> Self {
|
||||
Self {
|
||||
source: SourceId::from_word(source),
|
||||
badge: SourceBadge::from_word(Some(source)),
|
||||
kind: Some(kind),
|
||||
id: id.to_string(),
|
||||
is_local: None,
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a reference when the caller already holds a typed [`SourceId`].
|
||||
pub fn typed(source: SourceId, kind: ItemKind, id: &str) -> Self {
|
||||
Self {
|
||||
source: Some(source),
|
||||
badge: SourceBadge::from_word(Some(source.as_str())),
|
||||
kind: Some(kind),
|
||||
id: id.to_string(),
|
||||
is_local: None,
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a reference from a live queue row.
|
||||
///
|
||||
/// Moved from the four hand-written routers that read these same fields:
|
||||
/// `local_playback::play_audible` (local_playback.rs:237-245),
|
||||
/// `local_album_actions::play_audible` (local_album_actions.rs:439-450),
|
||||
/// `local_playback::play_current_if_local` (:253-264) and
|
||||
/// `cast_qt::cast_track` (cast_qt.rs:459-478).
|
||||
pub fn from_queue_track(t: &QueueTrack) -> Self {
|
||||
let word = t.source.as_deref().unwrap_or("");
|
||||
Self {
|
||||
source: SourceId::from_word(word),
|
||||
badge: SourceBadge::from_word(Some(word)),
|
||||
kind: Some(ItemKind::Track),
|
||||
id: t.id.to_string(),
|
||||
is_local: Some(t.is_local),
|
||||
hint: t.source_item_id_hint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a reference from a persisted MyQBZ collection item.
|
||||
///
|
||||
/// `AlbumSource::Local` is deliberately NOT mapped to [`SourceId::LOCAL`].
|
||||
/// The model's own doc comment says so (`qbz-models/src/mixtape.rs:39-43`):
|
||||
/// `Local` is an UMBRELLA covering file / plex / qobuz_download / …, so
|
||||
/// every producer of a Plex item has to store it as `Local` (survey IC-6).
|
||||
/// Mapping it to `LOCAL` would make `LocalSource` claim `plex:<hash>` keys
|
||||
/// and collide with `PlexSource`. It means "not Qobuz", which is exactly
|
||||
/// `is_local = Some(true)` — and the id shape then decides.
|
||||
pub fn from_mixtape_item(it: &MixtapeCollectionItem) -> Self {
|
||||
let (source, is_local) = match it.source {
|
||||
AlbumSource::Qobuz => (Some(SourceId::QOBUZ), Some(false)),
|
||||
AlbumSource::Local => (None, Some(true)),
|
||||
};
|
||||
Self {
|
||||
source,
|
||||
badge: match it.source {
|
||||
AlbumSource::Qobuz => SourceBadge::Qobuz,
|
||||
AlbumSource::Local => SourceBadge::Local,
|
||||
},
|
||||
kind: Some(ItemKind::from_item_type(it.item_type)),
|
||||
id: it.source_item_id.clone(),
|
||||
is_local,
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `self.id` parsed as an unsigned integer, or `None`.
|
||||
///
|
||||
/// THE single numeric accessor. There is deliberately no separate
|
||||
/// `numeric_id` field: the queue path and the mixtape path put the same
|
||||
/// number in different places (a live `QueueTrack.id` is a `u64`; a
|
||||
/// `MixtapeCollectionItem.source_item_id` is that same number as a
|
||||
/// `String`), and a design that reads only the former silently drops every
|
||||
/// mixtape Plex TRACK item (survey IC-3).
|
||||
pub fn numeric(&self) -> Option<u64> {
|
||||
self.id.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// The hint, trimmed, when it is present and non-empty.
|
||||
pub fn hint_str(&self) -> Option<&str> {
|
||||
self.hint.as_deref().map(str::trim).filter(|h| !h.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// A NORMALISED, source-owned reference.
|
||||
///
|
||||
/// Fields are `pub(crate)`: the ONLY way to obtain one outside this crate is
|
||||
/// [`crate::SourceRegistry::claim`]. See the module docs for why.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct MediaRef {
|
||||
pub(crate) source: SourceId,
|
||||
pub(crate) kind: ItemKind,
|
||||
pub(crate) id: String,
|
||||
}
|
||||
|
||||
impl MediaRef {
|
||||
/// Crate-private constructor. Only a `Source::claim` implementation calls
|
||||
/// it, which is what makes the id shape the source's property.
|
||||
pub(crate) fn new(source: SourceId, kind: ItemKind, id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
source,
|
||||
kind,
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which source owns this reference.
|
||||
pub fn source(&self) -> SourceId {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// What kind of thing it names.
|
||||
pub fn kind(&self) -> ItemKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// The normalised id — safe to persist and to round-trip back through
|
||||
/// [`RawRef`], and meaningless to interpret. Read it for logging and for
|
||||
/// storage; never parse it.
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Round-trip back to a caller-side reference (for persisting a queue row
|
||||
/// or re-claiming later). The source word is stamped, so the round trip is
|
||||
/// unambiguous.
|
||||
pub fn to_raw(&self) -> RawRef {
|
||||
RawRef::typed(self.source, self.kind, &self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MediaRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}:{}", self.source, self.kind.label(), self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_words_map_through_one_table() {
|
||||
// survey §1: six vocabularies, one mapping.
|
||||
assert_eq!(SourceId::from_word("qobuz"), Some(SourceId::QOBUZ));
|
||||
assert_eq!(SourceId::from_word("plex"), Some(SourceId::PLEX));
|
||||
assert_eq!(SourceId::from_word("local"), Some(SourceId::LOCAL));
|
||||
assert_eq!(SourceId::from_word("user"), Some(SourceId::LOCAL));
|
||||
assert_eq!(SourceId::from_word("qobuz_download"), Some(SourceId::LOCAL));
|
||||
assert_eq!(SourceId::from_word("qobuz_purchase"), Some(SourceId::LOCAL));
|
||||
assert_eq!(SourceId::from_word("offline"), Some(SourceId::LOCAL));
|
||||
assert_eq!(SourceId::from_word("ephemeral"), Some(SourceId::LOCAL));
|
||||
// Unknown / absent is NOT guessed into Qobuz (survey IC-6).
|
||||
assert_eq!(SourceId::from_word(""), None);
|
||||
assert_eq!(SourceId::from_word("jellyfin"), None);
|
||||
assert_eq!(SourceId::from_word("???"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_is_local_source_but_offline_badge() {
|
||||
// "an offline copy is a FILE; the distinction is a badge, not a source"
|
||||
let r = RawRef::new("qobuz_download", ItemKind::Track, "139578884");
|
||||
assert_eq!(r.source, Some(SourceId::LOCAL));
|
||||
assert_eq!(r.badge, SourceBadge::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_reads_the_id_on_both_paths() {
|
||||
// survey IC-3: the queue path holds a u64, the mixtape path holds the
|
||||
// same number as a String. One accessor, both paths.
|
||||
assert_eq!(
|
||||
RawRef::new("plex", ItemKind::Track, "1099511671736").numeric(),
|
||||
Some(1_099_511_671_736)
|
||||
);
|
||||
assert_eq!(
|
||||
RawRef::new("qobuz", ItemKind::Album, "e94no900otyrz").numeric(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
RawRef::new("qobuz", ItemKind::Album, "0060254702523").numeric(),
|
||||
Some(60_254_702_523)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixtape_local_umbrella_is_not_the_local_source() {
|
||||
// qbz-models/src/mixtape.rs:39-43 — `Local` covers file/plex/download.
|
||||
let it = MixtapeCollectionItem {
|
||||
collection_id: "c".into(),
|
||||
position: 0,
|
||||
item_type: ItemType::Album,
|
||||
source: AlbumSource::Local,
|
||||
source_item_id: "plex:5677211365378243606".into(),
|
||||
title: "t".into(),
|
||||
subtitle: None,
|
||||
artwork_url: None,
|
||||
year: None,
|
||||
track_count: None,
|
||||
added_at: 0,
|
||||
};
|
||||
let raw = RawRef::from_mixtape_item(&it);
|
||||
assert_eq!(raw.source, None, "Local is an umbrella, not SourceId::LOCAL");
|
||||
assert_eq!(raw.is_local, Some(true));
|
||||
assert_eq!(raw.kind, Some(ItemKind::Album));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `qbz-source` — the source-agnostic seam.
|
||||
//!
|
||||
//! # The problem this exists to delete
|
||||
//!
|
||||
//! Views mix items from Qobuz, local folders and Plex in ONE list, and every
|
||||
//! view re-derives the per-source handling by hand. Four concerns that should
|
||||
//! be one seam each are decided in **7 + 5 + 4 + ~15** separate places
|
||||
//! (`01-survey.md` §1), and splitting them is mechanically why "siempre falta
|
||||
//! algo": a view that learned two of the three sniffs silently drops the third,
|
||||
//! and nothing fails loudly — it renders a placeholder disc, returns an empty
|
||||
//! track list, or 404s a resolve.
|
||||
//!
|
||||
//! | 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 |
|
||||
//! | 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 |
|
||||
//!
|
||||
//! This crate collapses that table into **one trait with four methods and one
|
||||
//! registry**: [`Source`] and [`SourceRegistry`].
|
||||
//!
|
||||
//! # The three measured bugs, and where each dies
|
||||
//!
|
||||
//! 1. **`library.db` reopened per item** (23–34 opens for ONE MyQBZ
|
||||
//! collection). `LocalSource` owns a connection pool; `local_state::with_db`
|
||||
//! and `library_db_qt::with_db` keep their signatures and forward to
|
||||
//! [`sources::LocalSource::with`] / `with_creating`, so their 48 call sites
|
||||
//! are untouched and only the number of opens changes.
|
||||
//! 2. **A `plex:<hash>` album key used as a rating key** → 404, the track never
|
||||
//! starts and the PREVIOUS track keeps playing under the new card. Killed by
|
||||
//! four independent properties: [`MediaRef`] has no public constructor;
|
||||
//! [`SourceRegistry::claim`] is the single normalisation point on EVERY path
|
||||
//! including playback; [`PlaybackTicket`] is opaque data with no field a
|
||||
//! source-specific id could be smuggled through; and there is exactly one
|
||||
//! router.
|
||||
//! 3. **Missing artwork on the local rows of a hybrid list.** [`Source::artwork`]
|
||||
//! is on the trait, so a hybrid list makes ONE call per row regardless of
|
||||
//! source and cannot wire fewer pipelines than it has sources.
|
||||
//!
|
||||
//! # Shape rules
|
||||
//!
|
||||
//! - **Additive.** Nothing in `qbz/src` (Slint) or `qbz-mixtape` changes; this
|
||||
//! crate implements `qbz_mixtape::ItemResolver`, it does not absorb it.
|
||||
//! - **The PROTECTED audio backend is not linked.** `qbz-audio` and
|
||||
//! `qbz-player` are not dependencies, so sample rate, resampling and device
|
||||
//! selection are unreachable from here. [`PlaybackTicket`] is data; the
|
||||
//! frontend performs the entry.
|
||||
//! - **No executor.** This crate never spawns and does not depend on `tokio`.
|
||||
//! The blocking arms (SQLite, `stat`) stay blocking and the caller keeps
|
||||
//! wrapping them in `spawn_blocking` exactly where it does today.
|
||||
//! - **Three impls, one registry, one obvious slot for a fourth.** No dynamic
|
||||
//! registry, no config loading, no trait hierarchy.
|
||||
//!
|
||||
//! # Adding a fourth source (Jellyfin, Navidrome)
|
||||
//!
|
||||
//! `sources/jellyfin.rs` (one impl), one `pub mod` + `pub use` in
|
||||
//! [`sources`], one [`SourceId`] constant, one word in
|
||||
//! [`SourceId::from_word`], one [`SourceBadge`] variant, and one line in
|
||||
//! [`SourceRegistry::with_defaults`]. **Zero** changes in any view, any
|
||||
//! playback path, any artwork path — because all four concerns hang off the one
|
||||
//! trait.
|
||||
|
||||
pub mod art;
|
||||
pub mod error;
|
||||
pub mod id;
|
||||
pub mod meta;
|
||||
pub mod mixtape_adapter;
|
||||
pub mod playback;
|
||||
pub mod registry;
|
||||
pub mod source;
|
||||
pub mod sources;
|
||||
|
||||
pub use art::{ArtRef, ArtSize, PLEX_THUMB_PX};
|
||||
pub use error::SourceError;
|
||||
pub use id::{ItemKind, MediaRef, RawRef, SourceId};
|
||||
pub use meta::{ItemMeta, QualityHint, SourceBadge};
|
||||
pub use mixtape_adapter::RegistryResolver;
|
||||
pub use playback::PlaybackTicket;
|
||||
pub use registry::{registry, SourceRegistry};
|
||||
pub use source::Source;
|
||||
pub use sources::{
|
||||
local_queue_track, EphemeralTracks, LocalSource, PlexCreds, PlexSource, QobuzSource,
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Row-level display fields, produced by the source that owns the item.
|
||||
//!
|
||||
//! `PlayOrigin` / `OriginKind` are deliberately ABSENT: `playback_qt::
|
||||
//! derive_context` (playback_qt.rs:135-347) stays in charge of queue-level
|
||||
//! origin, and both current queue builders leave `context_kind`/`context_id`
|
||||
//! `None` on purpose (local_playback.rs:96-104 spells out why). A trait method
|
||||
//! every implementor would return `None` from is a defect, not an abstraction.
|
||||
|
||||
use crate::art::ArtRef;
|
||||
|
||||
/// Everything a row needs to draw itself, without expanding the item into
|
||||
/// tracks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ItemMeta {
|
||||
pub title: String,
|
||||
/// Artist / album artist.
|
||||
pub subtitle: String,
|
||||
pub year: Option<u32>,
|
||||
pub track_count: Option<u32>,
|
||||
pub duration_secs: Option<u64>,
|
||||
pub quality: QualityHint,
|
||||
pub art: ArtRef,
|
||||
pub badge: SourceBadge,
|
||||
/// UNTRANSLATED kind key (`"album"` | `"track"` | `"playlist"` | `"ep"` |
|
||||
/// `"single"`). i18n stays in the frontend — this crate never links
|
||||
/// `qbz-i18n`.
|
||||
pub kind_label: &'static str,
|
||||
}
|
||||
|
||||
/// The quality facts a badge is derived from.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct QualityHint {
|
||||
pub bit_depth: Option<u32>,
|
||||
/// kHz, the `QueueTrack.sample_rate` convention (NOT Hz).
|
||||
pub sample_rate_khz: Option<f64>,
|
||||
/// The container/codec name as the source spells it (`"FLAC"`, `"MP3"`,
|
||||
/// `"flac"`…). Compared case-insensitively.
|
||||
pub format: &'static str,
|
||||
}
|
||||
|
||||
impl QualityHint {
|
||||
/// Build from a raw Hz sample rate (the `LocalTrack` / Plex-cache
|
||||
/// convention) and a format name.
|
||||
///
|
||||
/// 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`.
|
||||
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
|
||||
} else {
|
||||
sample_rate_hz
|
||||
};
|
||||
Self {
|
||||
bit_depth,
|
||||
sample_rate_khz: (khz > 0.0).then_some(khz),
|
||||
format,
|
||||
}
|
||||
}
|
||||
|
||||
/// `""` | `"mp3"` | `"cd"` | `"hires"` | `"max"`.
|
||||
///
|
||||
/// Moved from `local_rows::tier_of` (local_rows.rs:180-196) so a local card
|
||||
/// and a Qobuz card can never disagree — which was its whole reason for
|
||||
/// existing. The derivation ORDER (MP3 first, then max / hires / cd) is
|
||||
/// preserved verbatim.
|
||||
pub fn tier(&self) -> &'static str {
|
||||
if self.format.eq_ignore_ascii_case("mp3") {
|
||||
return "mp3";
|
||||
}
|
||||
let khz = self.sample_rate_khz.unwrap_or(0.0);
|
||||
match self.bit_depth {
|
||||
Some(b) if b >= 24 && khz > 96.0 => "max",
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None if khz >= 44.1 => "cd",
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The row badge.
|
||||
///
|
||||
/// Moved from `local_rows::badge_source` (local_rows.rs:223-229): a Qobuz
|
||||
/// DOWNLOAD is stored `qobuz_download` but badges as `offline`, and exactly one
|
||||
/// place should know that.
|
||||
///
|
||||
/// The `Qobuz` arm is new. `badge_source` is only ever called with a
|
||||
/// `qbz_library` `source` column, which never contains `"qobuz"`, so its `_ =>
|
||||
/// local` fallback covered it by accident; here the Qobuz source needs a badge
|
||||
/// of its own and the mapping is explicit.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum SourceBadge {
|
||||
#[default]
|
||||
Local,
|
||||
Offline,
|
||||
Plex,
|
||||
Qobuz,
|
||||
}
|
||||
|
||||
impl SourceBadge {
|
||||
/// The badge for a raw source word (`LocalTrack.source`,
|
||||
/// `QueueTrack.source`). Absent / unknown badges as `Local`, matching
|
||||
/// `badge_source`'s `_` arm.
|
||||
pub fn from_word(raw: Option<&str>) -> SourceBadge {
|
||||
match raw.map(str::trim) {
|
||||
Some("plex") => SourceBadge::Plex,
|
||||
Some("qobuz_download") | Some("qobuz_purchase") | Some("offline") => {
|
||||
SourceBadge::Offline
|
||||
}
|
||||
Some("qobuz") => SourceBadge::Qobuz,
|
||||
_ => SourceBadge::Local,
|
||||
}
|
||||
}
|
||||
|
||||
/// The QML badge string (`SourceIcon.qml:43-50`'s `kind` vocabulary).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SourceBadge::Local => "local",
|
||||
SourceBadge::Offline => "offline",
|
||||
SourceBadge::Plex => "plex",
|
||||
SourceBadge::Qobuz => "qobuz",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tier_matches_local_rows_tier_of() {
|
||||
// local_rows.rs:180-196 — MP3 wins over everything.
|
||||
assert_eq!(
|
||||
QualityHint::from_hz(Some(24), 192_000.0, "MP3").tier(),
|
||||
"mp3"
|
||||
);
|
||||
assert_eq!(
|
||||
QualityHint::from_hz(Some(24), 192_000.0, "FLAC").tier(),
|
||||
"max"
|
||||
);
|
||||
assert_eq!(QualityHint::from_hz(Some(24), 96_000.0, "FLAC").tier(), "hires");
|
||||
assert_eq!(QualityHint::from_hz(Some(16), 44_100.0, "FLAC").tier(), "cd");
|
||||
assert_eq!(QualityHint::from_hz(None, 44_100.0, "FLAC").tier(), "cd");
|
||||
assert_eq!(QualityHint::from_hz(None, 0.0, "").tier(), "");
|
||||
// kHz input passes through untouched (the QueueTrack convention).
|
||||
assert_eq!(QualityHint::from_hz(Some(24), 192.0, "FLAC").tier(), "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badge_matches_badge_source() {
|
||||
assert_eq!(SourceBadge::from_word(Some("plex")), SourceBadge::Plex);
|
||||
assert_eq!(
|
||||
SourceBadge::from_word(Some("qobuz_download")),
|
||||
SourceBadge::Offline
|
||||
);
|
||||
assert_eq!(
|
||||
SourceBadge::from_word(Some("qobuz_purchase")),
|
||||
SourceBadge::Offline
|
||||
);
|
||||
assert_eq!(SourceBadge::from_word(Some("user")), SourceBadge::Local);
|
||||
assert_eq!(SourceBadge::from_word(None), SourceBadge::Local);
|
||||
assert_eq!(SourceBadge::from_word(Some("qobuz")), SourceBadge::Qobuz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 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.
|
||||
|
||||
use qbz_models::mixtape::MixtapeCollectionItem;
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::id::RawRef;
|
||||
use crate::registry::SourceRegistry;
|
||||
|
||||
/// A `qbz_mixtape::ItemResolver` backed by the whole registry.
|
||||
///
|
||||
/// It replaces `ProdItemResolver::new(&client, resolve_local)` at the five
|
||||
/// construction sites in `myqbz_play_qt.rs` (:148, :159, :193, :235, :533) and
|
||||
/// deletes `resolve_local` (:110-115) — the function that IS bug 1's measured
|
||||
/// 23–34 opens per collection.
|
||||
///
|
||||
/// **No lifetime parameter:** the registry is `'static`, and a
|
||||
/// `RegistryResolver<'a>` would only re-import the borrow that stops
|
||||
/// `ProdItemResolver` (which borrows a stack-local client clone) from being
|
||||
/// moved into a spawned task.
|
||||
///
|
||||
/// **Behavioural note for review:** this resolver takes NO Qobuz client. A
|
||||
/// Qobuz item in a collection therefore fails PER ITEM with
|
||||
/// `NotConfigured { by: QOBUZ, why: "no client" }` — which
|
||||
/// `resolve_collection_tracks` logs and skips (enqueue.rs:58-66) — while the
|
||||
/// local and Plex items in the same collection resolve normally. Today the
|
||||
/// *whole* resolve returns an empty vec before the resolver is even built
|
||||
/// (myqbz_play_qt.rs:145, :156, :190, :232). That is survey IC-5, and it is the
|
||||
/// direct upstream cause of bug 3's MyQBZ half.
|
||||
pub struct RegistryResolver {
|
||||
registry: &'static SourceRegistry,
|
||||
}
|
||||
|
||||
impl RegistryResolver {
|
||||
/// Wrap the process-wide registry (`crate::registry()`).
|
||||
pub fn new(registry: &'static SourceRegistry) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl qbz_mixtape::ItemResolver for RegistryResolver {
|
||||
async fn resolve(&self, item: &MixtapeCollectionItem) -> Result<Vec<QueueTrack>, String> {
|
||||
let raw = RawRef::from_mixtape_item(item);
|
||||
let media = self.registry.claim(&raw).map_err(|e| e.to_string())?;
|
||||
self.registry
|
||||
.tracks(&media)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! What the player needs to start a track — as DATA.
|
||||
//!
|
||||
//! This crate does not link `qbz-player` or `qbz-audio` and therefore *cannot*
|
||||
//! touch sample rate, resampling or device selection. The ticket says what to
|
||||
//! do; the frontend performs the entry into the PROTECTED audio path, at the
|
||||
//! same `play_data` / `play_dsd_file` / `play_track_resolved` seams it uses
|
||||
//! today.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Everything the frontend needs to enter the PROTECTED audio path.
|
||||
///
|
||||
/// **NOT `#[non_exhaustive]`, deliberately.** `qbz-source` and `qbz-qt` ship in
|
||||
/// the same workspace and are versioned together; the attribute would force the
|
||||
/// frontend's `match` to carry a `_ =>` arm, which silently swallows a variant
|
||||
/// added later. That is the opposite of the "the compiler will not let you skip
|
||||
/// a source" property the whole design leans on. When the documented
|
||||
/// progressive-streaming follow-up (local_playback.rs:186-192) adds
|
||||
/// `Stream { .. }`, the frontend match failing to compile is the feature.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum PlaybackTicket {
|
||||
/// Read this file and hand the bytes to `player().play_data(bytes, play_id)`.
|
||||
///
|
||||
/// `seek_secs` is the CUE virtual-track offset (local_playback.rs:152-158,
|
||||
/// :177-180): every virtual track of a CUE album shares ONE audio file, so
|
||||
/// the frontend seeks after the load lands.
|
||||
File {
|
||||
path: PathBuf,
|
||||
play_id: u64,
|
||||
seek_secs: Option<f64>,
|
||||
},
|
||||
/// Stream from disk: `player().play_dsd_file(path, play_id)`
|
||||
/// (local_playback.rs:139-149). DSD stays on its additive path, untouched.
|
||||
DsdFile { path: PathBuf, play_id: u64 },
|
||||
/// The source already fetched the ORIGINAL bytes (Plex direct-play, no
|
||||
/// transcode requested): `player().play_data(bytes, play_id)`
|
||||
/// (local_playback.rs:199-206).
|
||||
Bytes { bytes: Vec<u8>, play_id: u64 },
|
||||
/// Let the core resolve + stream it: `core().play_track_resolved(track_id, …)`.
|
||||
Catalog { track_id: u64 },
|
||||
/// The current track is already loaded and this is a seek within the same
|
||||
/// container — the CUE fast path (local_playback.rs:150-158).
|
||||
///
|
||||
/// NOTE (stage-1 status): **no source produces this yet.** The "is this
|
||||
/// container already loaded?" test reads `player().state.current_track_id()`
|
||||
/// and `has_loaded_audio()`, which this crate cannot link (§8 — the
|
||||
/// PROTECTED backend is not a dependency). `LocalSource::playback` emits
|
||||
/// `File { seek_secs: Some(_) }` instead and the frontend keeps making that
|
||||
/// decision where it makes it today. Kept because the design specifies it;
|
||||
/// if stage 3 confirms the frontend never wants a source to say it, this is
|
||||
/// the variant to cut.
|
||||
SeekLoaded { play_id: u64, secs: f64 },
|
||||
}
|
||||
|
||||
/// Hand-written so `Bytes` prints its LENGTH, not a megabyte of FLAC. A
|
||||
/// derived `Debug` would dump the whole buffer into any log line that formats
|
||||
/// a ticket.
|
||||
impl std::fmt::Debug for PlaybackTicket {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PlaybackTicket::File {
|
||||
path,
|
||||
play_id,
|
||||
seek_secs,
|
||||
} => f
|
||||
.debug_struct("File")
|
||||
.field("path", path)
|
||||
.field("play_id", play_id)
|
||||
.field("seek_secs", seek_secs)
|
||||
.finish(),
|
||||
PlaybackTicket::DsdFile { path, play_id } => f
|
||||
.debug_struct("DsdFile")
|
||||
.field("path", path)
|
||||
.field("play_id", play_id)
|
||||
.finish(),
|
||||
PlaybackTicket::Bytes { bytes, play_id } => f
|
||||
.debug_struct("Bytes")
|
||||
.field("len", &bytes.len())
|
||||
.field("play_id", play_id)
|
||||
.finish(),
|
||||
PlaybackTicket::Catalog { track_id } => f
|
||||
.debug_struct("Catalog")
|
||||
.field("track_id", track_id)
|
||||
.finish(),
|
||||
PlaybackTicket::SeekLoaded { play_id, secs } => f
|
||||
.debug_struct("SeekLoaded")
|
||||
.field("play_id", play_id)
|
||||
.field("secs", secs)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackTicket {
|
||||
/// The player-side id this ticket plays under, when it has one.
|
||||
pub fn play_id(&self) -> Option<u64> {
|
||||
match self {
|
||||
PlaybackTicket::File { play_id, .. }
|
||||
| PlaybackTicket::DsdFile { play_id, .. }
|
||||
| PlaybackTicket::Bytes { play_id, .. }
|
||||
| PlaybackTicket::SeekLoaded { play_id, .. } => Some(*play_id),
|
||||
PlaybackTicket::Catalog { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! The registry: one process-wide table, and the ONE place a raw reference
|
||||
//! becomes a normalised one.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::art::{ArtRef, ArtSize};
|
||||
use crate::error::SourceError;
|
||||
use crate::id::{MediaRef, RawRef, SourceId};
|
||||
use crate::meta::ItemMeta;
|
||||
use crate::playback::PlaybackTicket;
|
||||
use crate::source::Source;
|
||||
use crate::sources::{LocalSource, PlexSource, QobuzSource};
|
||||
|
||||
/// Every source in the process.
|
||||
pub struct SourceRegistry {
|
||||
sources: Vec<Arc<dyn Source>>,
|
||||
// Typed handles for the three that exist. This is NOT a plugin table: the
|
||||
// auth path has to hand each of them something only IT understands (a Qobuz
|
||||
// client, a Plex creds impl, a user directory + the ephemeral store), and
|
||||
// `&Arc<dyn Source>` cannot express that. Three concrete accessors beat one
|
||||
// downcast.
|
||||
qobuz: Arc<QobuzSource>,
|
||||
plex: Arc<PlexSource>,
|
||||
local: Arc<LocalSource>,
|
||||
}
|
||||
|
||||
impl SourceRegistry {
|
||||
/// THE registration site. Adding a fourth source is one line here.
|
||||
///
|
||||
/// Order is NOT load-bearing: [`SourceRegistry::claim`] asks everyone (see
|
||||
/// its docs), which removes an invariant a future author could break by
|
||||
/// inserting a source in the wrong slot.
|
||||
pub fn with_defaults() -> Self {
|
||||
let qobuz = Arc::new(QobuzSource::new());
|
||||
let plex = Arc::new(PlexSource::new());
|
||||
let local = Arc::new(LocalSource::new());
|
||||
Self {
|
||||
sources: vec![
|
||||
qobuz.clone() as Arc<dyn Source>,
|
||||
plex.clone() as Arc<dyn Source>,
|
||||
local.clone() as Arc<dyn Source>,
|
||||
],
|
||||
qobuz,
|
||||
plex,
|
||||
local,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registered source with this id.
|
||||
pub fn get(&self, id: SourceId) -> Option<&Arc<dyn Source>> {
|
||||
self.sources.iter().find(|s| s.id() == id)
|
||||
}
|
||||
|
||||
/// The Qobuz source, for `set_client` from the auth path.
|
||||
pub fn qobuz(&self) -> &QobuzSource {
|
||||
&self.qobuz
|
||||
}
|
||||
|
||||
/// The Plex source, for `set_creds` from the settings path.
|
||||
pub fn plex(&self) -> &PlexSource {
|
||||
&self.plex
|
||||
}
|
||||
|
||||
/// The local source.
|
||||
///
|
||||
/// PUBLIC because it carries the `library.db` accessors
|
||||
/// `local_state::with_db` and `library_db_qt::with_db` forward to (design
|
||||
/// §9 stage 1). The pool itself stays crate-private behind them.
|
||||
pub fn local(&self) -> &LocalSource {
|
||||
&self.local
|
||||
}
|
||||
|
||||
/// Normalise a caller's reference — the ONE place id-shape knowledge is
|
||||
/// applied, on EVERY path including playback.
|
||||
///
|
||||
/// A two-pass walk that refuses to guess:
|
||||
///
|
||||
/// **Pass 1 — POSITIVE ownership only.** Every source is asked, and a
|
||||
/// source may answer `Some` only on evidence it *owns*, never on "nobody
|
||||
/// else looked like it".
|
||||
///
|
||||
/// - exactly one `Some(Ok)` → that is the answer;
|
||||
/// - any `Some(Err)` → returned immediately. A recognised-but-wrong shape
|
||||
/// wins over a weaker positive claim: that is what turns bug 2 into a log
|
||||
/// line at the point of the mistake instead of a 404 two layers down;
|
||||
/// - more than one `Some(Ok)` → [`SourceError::Ambiguous`] listing the
|
||||
/// candidates. It cannot happen with today's three (their predicates are
|
||||
/// disjoint), and the day it can, the registry says so instead of
|
||||
/// picking.
|
||||
///
|
||||
/// **Pass 2 — none of the above.** `Ambiguous` when the id is a bare
|
||||
/// numeric (genuinely undecidable without a source word, survey §6.3),
|
||||
/// otherwise `Unclaimed`. **The registry never falls back to Qobuz** —
|
||||
/// `myqbz_add_qt::source_from_str`'s `_ => Qobuz` (survey IC-6) is exactly
|
||||
/// the fallback being deleted, and re-creating it here would re-create IC-4
|
||||
/// and IC-6 in one place instead of two.
|
||||
pub fn claim(&self, raw: &RawRef) -> Result<MediaRef, SourceError> {
|
||||
let mut hits: Vec<MediaRef> = Vec::new();
|
||||
for s in &self.sources {
|
||||
match s.claim(raw) {
|
||||
None => {}
|
||||
Some(Err(e)) => return Err(e),
|
||||
Some(Ok(m)) => hits.push(m),
|
||||
}
|
||||
}
|
||||
match hits.len() {
|
||||
1 => Ok(hits.remove(0)),
|
||||
0 => {
|
||||
if raw.numeric().is_some() {
|
||||
Err(SourceError::Ambiguous {
|
||||
id: raw.id.clone(),
|
||||
kind: raw.kind,
|
||||
candidates: self.sources.iter().map(|s| s.id()).collect(),
|
||||
})
|
||||
} else {
|
||||
Err(SourceError::Unclaimed(raw.clone()))
|
||||
}
|
||||
}
|
||||
_ => Err(SourceError::Ambiguous {
|
||||
id: raw.id.clone(),
|
||||
kind: raw.kind,
|
||||
candidates: hits.iter().map(|m| m.source()).collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner(&self, item: &MediaRef) -> Result<&Arc<dyn Source>, SourceError> {
|
||||
self.get(item.source()).ok_or(SourceError::NotConfigured {
|
||||
by: item.source(),
|
||||
why: "source is not registered",
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand an item into playable queue tracks, IN ORDER.
|
||||
pub async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
|
||||
self.owner(item)?.tracks(item).await
|
||||
}
|
||||
|
||||
/// Claim + expand, for the common call shape.
|
||||
pub async fn tracks_of(&self, raw: &RawRef) -> Result<Vec<QueueTrack>, SourceError> {
|
||||
let item = self.claim(raw)?;
|
||||
self.tracks(&item).await
|
||||
}
|
||||
|
||||
/// Row-level display fields, without expanding the item.
|
||||
pub async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
|
||||
self.owner(item)?.meta(item).await
|
||||
}
|
||||
|
||||
/// The CHEAP artwork phase. BLOCKING; run it on `spawn_blocking`.
|
||||
pub fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
|
||||
match self.get(item.source()) {
|
||||
Some(s) => s.artwork(item, size),
|
||||
None => ArtRef::Unavailable("source is not registered"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The EXPENSIVE artwork phase, keyed by [`ArtRef`] rather than
|
||||
/// [`MediaRef`] — the windowed pipeline it serves has no item in scope.
|
||||
pub fn thumbnail(&self, source: SourceId, art: &ArtRef, size: ArtSize) -> ArtRef {
|
||||
match self.get(source) {
|
||||
Some(s) => s.thumbnail(art, size),
|
||||
None => art.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// THE playback router. Replaces BOTH copies of `play_audible`
|
||||
/// (local_playback.rs:237-245 and local_album_actions.rs:439-450).
|
||||
///
|
||||
/// It claims FIRST, then dispatches — so the playback path goes through the
|
||||
/// SAME single normalisation point as everything else, and `MediaRef`'s
|
||||
/// no-public-constructor property actually covers it. Matching on
|
||||
/// `track.source` here instead would bypass `claim` entirely and leave bug
|
||||
/// 2's structural argument unsupported on the one path bug 2 lives on.
|
||||
pub async fn playback(&self, track: &QueueTrack) -> Result<PlaybackTicket, SourceError> {
|
||||
let item = self.claim(&RawRef::from_queue_track(track))?;
|
||||
self.owner(&item)?.playback(&item, track).await
|
||||
}
|
||||
|
||||
/// Bind every source to the active user.
|
||||
///
|
||||
/// Call this as the FIRST statement of `auth_qt::bind_per_user_stores`
|
||||
/// (auth_qt.rs:202): `myqbz_qt::init_for_user` at `:206` runs the mixtape
|
||||
/// migrations through `library_db_qt::with_db(true, …)`, and against an
|
||||
/// unbound pool those return `None` and a fresh account can never create a
|
||||
/// collection (library_db_qt.rs:41-49).
|
||||
pub fn bind_user(&self, uid: u64, dir: &std::path::Path) {
|
||||
for s in &self.sources {
|
||||
s.bind_user(uid, dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every per-user handle and cache. Call from `auth_qt::logout`
|
||||
/// (auth_qt.rs:416-442).
|
||||
pub fn teardown(&self) {
|
||||
for s in &self.sources {
|
||||
s.teardown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SourceRegistry {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
static REGISTRY: OnceLock<SourceRegistry> = OnceLock::new();
|
||||
|
||||
/// The process-wide registry.
|
||||
///
|
||||
/// ONE per process; bound and torn down by the auth path, never by a view.
|
||||
/// It is `'static`, which is what lets [`crate::RegistryResolver`] be moved
|
||||
/// into a spawned task — something `qbz_mixtape::ProdItemResolver<'a, L>`, which
|
||||
/// borrows a stack-local client clone, cannot do.
|
||||
pub fn registry() -> &'static SourceRegistry {
|
||||
REGISTRY.get_or_init(SourceRegistry::with_defaults)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::id::ItemKind;
|
||||
|
||||
#[test]
|
||||
fn the_three_predicates_are_disjoint_on_the_real_id_table() {
|
||||
let r = SourceRegistry::with_defaults();
|
||||
|
||||
// Qobuz album id (barcode) with the source word.
|
||||
let m = r
|
||||
.claim(&RawRef::new("qobuz", ItemKind::Album, "0060254702523"))
|
||||
.unwrap();
|
||||
assert_eq!(m.source(), SourceId::QOBUZ);
|
||||
|
||||
// Plex content-hash album key inside a mixtape `Local` item.
|
||||
let m = r
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "plex:5677211365378243606".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(m.source(), SourceId::PLEX);
|
||||
|
||||
// Local metadata-mode group key (real row).
|
||||
let m = r
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "HIT ME HARD AND SOFT|Billie Eilish".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(m.source(), SourceId::LOCAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_numeric_with_no_evidence_is_refused_not_guessed() {
|
||||
// survey §6.3 / IC-4: `2954` can be a local row id, a Plex rating key
|
||||
// OR a Qobuz track id. Guessing is what favourites a random Qobuz
|
||||
// track when the user hearts a local one.
|
||||
let err = SourceRegistry::with_defaults()
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SourceError::Ambiguous { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_shape_is_unclaimed_not_qobuz() {
|
||||
let err = SourceRegistry::with_defaults()
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "who-knows".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SourceError::Unclaimed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recognised_but_wrong_shape_stops_the_walk() {
|
||||
// BUG 2's report path: the album boundary key in TRACK position is
|
||||
// rejected by Plex, and the walk does not fall through to a weaker
|
||||
// claimant and a 404 two layers down.
|
||||
let err = SourceRegistry::with_defaults()
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "plex:5677211365378243606".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_err();
|
||||
match err {
|
||||
SourceError::BadIdShape { by, .. } => assert_eq!(by, SourceId::PLEX),
|
||||
other => panic!("expected BadIdShape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_registered_source_is_reachable_by_id() {
|
||||
let r = SourceRegistry::with_defaults();
|
||||
for id in [SourceId::QOBUZ, SourceId::PLEX, SourceId::LOCAL] {
|
||||
assert_eq!(r.get(id).map(|s| s.id()), Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The trait. Four concerns, one `impl` block per source.
|
||||
//!
|
||||
//! A source cannot implement three of them and quietly skip artwork — the
|
||||
//! compiler will not let it. That is the property the whole seam rests on:
|
||||
//! today's four concerns are decided in 7 + 5 + 4 + ~15 separate places
|
||||
//! (survey §1), and splitting them is exactly what makes a view forget one.
|
||||
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::art::{ArtRef, ArtSize};
|
||||
use crate::error::SourceError;
|
||||
use crate::id::{MediaRef, RawRef, SourceId};
|
||||
use crate::meta::ItemMeta;
|
||||
use crate::playback::PlaybackTicket;
|
||||
|
||||
/// One music source: Qobuz, the local filesystem, a Plex server — and, when it
|
||||
/// arrives, Jellyfin or Navidrome.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Source: Send + Sync + 'static {
|
||||
/// Which source this is.
|
||||
fn id(&self) -> SourceId;
|
||||
|
||||
// ── OWNERSHIP / NORMALISATION ───────────────────────────────────────────
|
||||
|
||||
/// Does this source own `raw`, and if so, what is the CANONICAL reference?
|
||||
///
|
||||
/// - `None` — not mine; the registry asks the next source.
|
||||
/// - `Some(Ok(r))` — mine, normalised.
|
||||
/// - `Some(Err(e))` — MINE, and the shape is wrong in this position. The
|
||||
/// walk STOPS here: a recognised-but-rejected shape is a
|
||||
/// [`SourceError::BadIdShape`] at the moment of the mistake, not a
|
||||
/// fallthrough to the next source and a 404 two layers down. This is bug
|
||||
/// 2's report path.
|
||||
///
|
||||
/// ONE method, so a source cannot implement the recognition and forget the
|
||||
/// rejection — the exact "handles some sources and not others" shape the
|
||||
/// seam exists to kill.
|
||||
///
|
||||
/// Implementations claim on POSITIVE evidence they own, never on "nobody
|
||||
/// else looked like it" (see [`crate::SourceRegistry::claim`]).
|
||||
fn claim(&self, raw: &RawRef) -> Option<Result<MediaRef, SourceError>>;
|
||||
|
||||
// ── TRACKS ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Expand `item` into playable queue tracks, IN ORDER.
|
||||
///
|
||||
/// `source_item_id_hint` is left `None`: `qbz_mixtape::
|
||||
/// resolve_collection_tracks` stamps it centrally (enqueue.rs:52-56) and
|
||||
/// this crate must not fight it.
|
||||
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError>;
|
||||
|
||||
// ── CONTEXT / METADATA ──────────────────────────────────────────────────
|
||||
|
||||
/// Row-level display fields, WITHOUT expanding the item into tracks.
|
||||
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError>;
|
||||
|
||||
// ── ARTWORK ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// CHEAP phase: memo lookups, at most one `stat`, and — for `LocalSource`
|
||||
/// only — at most ONE indexed `library.db` query on a cold cover miss.
|
||||
/// NEVER decodes, never hits the network, never opens a connection (the
|
||||
/// pool owns that).
|
||||
///
|
||||
/// BLOCKING: the caller runs it on `spawn_blocking`, exactly where it does
|
||||
/// today (local_playback.rs:302, :313, :323).
|
||||
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef;
|
||||
|
||||
/// EXPENSIVE phase: may decode + downscale.
|
||||
///
|
||||
/// Takes an [`ArtRef`], NOT a [`MediaRef`] — the windowed pipeline it
|
||||
/// serves (`local_artwork::stream_cold`, local_artwork.rs:164-188) is keyed
|
||||
/// by `art_key` and has no item reference in scope (local_rows.rs:253,
|
||||
/// :283 → `local_state.art_index`).
|
||||
///
|
||||
/// Defaults to identity; only `LocalSource` overrides it. The cheap/cold
|
||||
/// split is preserved verbatim from `local_artwork.rs` — collapsing the two
|
||||
/// is the documented "15-second blank grid" regression
|
||||
/// (local_artwork.rs:23-46).
|
||||
fn thumbnail(&self, art: &ArtRef, _size: ArtSize) -> ArtRef {
|
||||
art.clone()
|
||||
}
|
||||
|
||||
// ── PLAYBACK ────────────────────────────────────────────────────────────
|
||||
|
||||
/// What the player needs to actually start THIS track.
|
||||
///
|
||||
/// `item` is the NORMALISED ref the registry already claimed — the source
|
||||
/// never re-derives its own id from `track`. `track` is passed only for the
|
||||
/// display/companion fields (`play_id`, cue offset) the ticket needs. The
|
||||
/// id the source's own API accepts is computed from `item.id()`, inside the
|
||||
/// source, and never escapes.
|
||||
async fn playback(
|
||||
&self,
|
||||
item: &MediaRef,
|
||||
track: &QueueTrack,
|
||||
) -> Result<PlaybackTicket, SourceError>;
|
||||
|
||||
// ── LIFECYCLE ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Bind to the active user's data directory.
|
||||
///
|
||||
/// Called from ONE place: `auth_qt::bind_per_user_stores` (auth_qt.rs:202),
|
||||
/// and it must be that function's FIRST statement — `myqbz_qt::
|
||||
/// init_for_user` (auth_qt.rs:206) runs the mixtape migrations through
|
||||
/// `library_db_qt::with_db(true, …)`, and against an unbound pool a fresh
|
||||
/// account can never create a collection (library_db_qt.rs:41-49).
|
||||
fn bind_user(&self, _uid: u64, _dir: &std::path::Path) {}
|
||||
|
||||
/// Drop every per-user handle and cache. Called from `auth_qt::logout`.
|
||||
///
|
||||
/// **Overriding this is MANDATORY for any source that holds per-user
|
||||
/// state.** The default is a no-op, so an implementor that forgets it leaks
|
||||
/// the previous account's handle into a `'static` registry — the leak class
|
||||
/// this port already shipped once (the blacklist leak, auth_qt.rs:193-197).
|
||||
fn teardown(&self) {}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
//! Files on this machine: the indexed `library.db`, Qobuz downloads/purchases
|
||||
//! and the session-scoped ephemeral folder.
|
||||
//!
|
||||
//! `local_ephemeral` is a *fourth flavour of local*, not a fourth source:
|
||||
//! session-scoped rather than user-scoped, floor-namespaced ids
|
||||
//! (`local_ephemeral.rs:64-65` over `qbz_library::ephemeral::
|
||||
//! EPHEMERAL_ID_FLOOR`). It is an arm inside this source, reached through the
|
||||
//! injected [`EphemeralTracks`] handle.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use qbz_library::album_grouping::AlbumGroupMode;
|
||||
use qbz_library::ephemeral::EPHEMERAL_ID_FLOOR;
|
||||
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::art::{ArtRef, ArtSize};
|
||||
use crate::error::SourceError;
|
||||
use crate::id::{ItemKind, MediaRef, RawRef, SourceId};
|
||||
use crate::meta::{ItemMeta, QualityHint, SourceBadge};
|
||||
use crate::playback::PlaybackTicket;
|
||||
use crate::source::Source;
|
||||
use crate::sources::pool::DbPool;
|
||||
|
||||
/// Memo ceiling, mirroring `local_artwork::MEMO_CAP` (local_artwork.rs:66) and
|
||||
/// `artwork_qt::MEMO_CAP` (artwork_qt.rs:82): an accelerator, cleared wholesale
|
||||
/// rather than carrying LRU bookkeeping.
|
||||
const MEMO_CAP: usize = 8192;
|
||||
|
||||
/// Per-source generation locks, sharded. Moved from `local_artwork::GEN_SHARDS`
|
||||
/// (local_artwork.rs:76-78).
|
||||
const GEN_SHARDS: usize = 64;
|
||||
|
||||
/// The session-scoped ephemeral store, injected by the frontend.
|
||||
///
|
||||
/// The store itself is a process-global in the frontend
|
||||
/// (`local_ephemeral.rs:59`, a `LazyLock<EphemeralLibraryState>`); opening a
|
||||
/// second copy here would be a second authority. Same injection shape as
|
||||
/// [`crate::PlexCreds`], for the same reason.
|
||||
pub trait EphemeralTracks: Send + Sync + 'static {
|
||||
/// Resolve a synthetic id to its cached row (`local_ephemeral::get_track`,
|
||||
/// local_ephemeral.rs:70-72).
|
||||
fn get_track(&self, id: i64) -> Option<LocalTrack>;
|
||||
/// The tracks of one ephemeral album block, in scan order
|
||||
/// (`local_ephemeral::album_tracks`, local_ephemeral.rs:95-101).
|
||||
fn album_tracks(&self, group_key: &str) -> Vec<LocalTrack>;
|
||||
}
|
||||
|
||||
/// Map a `LocalTrack` to a `QueueTrack`.
|
||||
///
|
||||
/// Moved VERBATIM from `local_playback::local_queue_track`
|
||||
/// (local_playback.rs:31-108), which is the most complete of the three mappers
|
||||
/// in the tree (`qbz-mixtape/enqueue.rs:513-553` drops the offline/Plex
|
||||
/// branches). Only one line changed: `crate::local_plex::album_key_for` became
|
||||
/// `qbz_plex::plex_album_key`, which is what that helper forwards to
|
||||
/// (local_plex.rs:326-328).
|
||||
///
|
||||
/// The `is_plex` branches are retained rather than deleted: a Plex row reaches
|
||||
/// this shape through `local_plex::map_cached_to_local_track`
|
||||
/// (local_plex.rs:276-302), and the LocalLibrary Tracks tab merges those rows
|
||||
/// into the same list it maps here.
|
||||
pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
|
||||
let src = match t.source.as_deref() {
|
||||
Some("qobuz_download") => "qobuz_download",
|
||||
Some("plex") => "plex",
|
||||
_ => "local",
|
||||
};
|
||||
let is_offline = src == "qobuz_download";
|
||||
let is_plex = src == "plex";
|
||||
// A Plex row carries a RAW server-relative thumb path; it must stay raw
|
||||
// so the now-playing bar / queue resolve it from current creds.
|
||||
// `file://`-prefixing it poisons it into a local-read miss.
|
||||
let artwork_url = t.artwork_path.as_ref().map(|p| {
|
||||
if is_plex || p.starts_with("file://") {
|
||||
p.clone()
|
||||
} else {
|
||||
format!("file://{p}")
|
||||
}
|
||||
});
|
||||
let sample_rate_khz = if t.sample_rate >= 1000.0 {
|
||||
t.sample_rate / 1000.0
|
||||
} else {
|
||||
t.sample_rate
|
||||
};
|
||||
QueueTrack {
|
||||
id: if is_offline {
|
||||
t.qobuz_track_id.unwrap_or(t.id) as u64
|
||||
} else {
|
||||
t.id as u64
|
||||
},
|
||||
title: t.title.clone(),
|
||||
version: None,
|
||||
artist: t.artist.clone(),
|
||||
album: t.album_group_title.clone(),
|
||||
album_version: None,
|
||||
duration_secs: t.duration_secs,
|
||||
artwork_url,
|
||||
hires: t.bit_depth.map(|d| d > 16).unwrap_or(false),
|
||||
bit_depth: t.bit_depth,
|
||||
sample_rate: Some(sample_rate_khz),
|
||||
is_local: true,
|
||||
// Navigation key. For Plex the track's `album_group_key` is the
|
||||
// per-edition split key, which the album cache is NOT keyed by —
|
||||
// recover the content-hash key so "go to album" resolves.
|
||||
album_id: Some(if is_plex {
|
||||
qbz_plex::plex_album_key(&t.artist, &t.album)
|
||||
} else {
|
||||
t.album_group_key.clone()
|
||||
}),
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: Some(src.to_string()),
|
||||
parental_warning: false,
|
||||
// Plex: the string rating_key the resolve needs (the numeric queue id
|
||||
// is a namespaced form). Offline: the local row id.
|
||||
source_item_id_hint: if is_plex || is_offline {
|
||||
Some(if is_plex {
|
||||
t.file_path.clone()
|
||||
} else {
|
||||
t.id.to_string()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// NO container origin — `playback_qt::derive_context` owns it
|
||||
// (local_playback.rs:94-104 explains at length why these stay None).
|
||||
context_kind: None,
|
||||
context_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The local-files source.
|
||||
pub struct LocalSource {
|
||||
pool: DbPool,
|
||||
ephemeral: RwLock<Option<Arc<dyn EphemeralTracks>>>,
|
||||
/// The ACTIVE album identity mode (`local_state::group_mode`, a persisted
|
||||
/// UI pref). Folder is the owner default
|
||||
/// (`qbz-library/src/album_grouping.rs:24-33`).
|
||||
mode: Mutex<AlbumGroupMode>,
|
||||
/// Cover source path → its generated thumbnail. Moved from
|
||||
/// `local_artwork::THUMBS` (local_artwork.rs:81-82).
|
||||
thumbs: RwLock<HashMap<String, PathBuf>>,
|
||||
/// Item id → its cover SOURCE path, so a repeat `artwork` call costs no
|
||||
/// query at all.
|
||||
covers: RwLock<HashMap<String, String>>,
|
||||
/// Per-source generation locks. Moved from `local_artwork::GEN_LOCKS`
|
||||
/// (local_artwork.rs:84-85): `qbz_library::generate_thumbnail` writes the
|
||||
/// jpeg straight to its final path (no temp+rename), so two workers must
|
||||
/// never generate the SAME source concurrently.
|
||||
gen_locks: Vec<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl LocalSource {
|
||||
/// A source with no user bound and no ephemeral store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pool: DbPool::new(),
|
||||
ephemeral: RwLock::new(None),
|
||||
mode: Mutex::new(AlbumGroupMode::Folder),
|
||||
thumbs: RwLock::new(HashMap::new()),
|
||||
covers: RwLock::new(HashMap::new()),
|
||||
gen_locks: (0..GEN_SHARDS).map(|_| Mutex::new(())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// THE `library.db` read accessor.
|
||||
///
|
||||
/// `local_state::with_db` (local_state.rs:39-61) forwards here with its
|
||||
/// signature unchanged, so its 48 call sites are untouched and only the
|
||||
/// number of `LibraryDatabase::open` calls changes — which is bug 1.
|
||||
pub fn with<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
|
||||
) -> Option<R> {
|
||||
self.pool.with(f)
|
||||
}
|
||||
|
||||
/// THE create-capable accessor. `library_db_qt::with_db(true, …)`
|
||||
/// (library_db_qt.rs:50-76) forwards here.
|
||||
pub fn with_creating<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
|
||||
) -> Option<R> {
|
||||
self.pool.with_creating(f)
|
||||
}
|
||||
|
||||
/// Install the unbound-window path computation. See [`DbPool`]'s
|
||||
/// `fallback` field: the frontend passes `local_state::db_path`, because
|
||||
/// `UserDataPaths` lives in `qbz-app` and this crate must not link it.
|
||||
pub fn set_unbound_fallback(
|
||||
&self,
|
||||
f: Option<Box<dyn Fn() -> Option<PathBuf> + Send + Sync>>,
|
||||
) {
|
||||
self.pool.set_unbound_fallback(f);
|
||||
}
|
||||
|
||||
/// Publish the session-scoped ephemeral store.
|
||||
pub fn set_ephemeral(&self, store: Option<Arc<dyn EphemeralTracks>>) {
|
||||
if let Ok(mut slot) = self.ephemeral.write() {
|
||||
*slot = store;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the ACTIVE album identity mode (the header dropdown / Settings
|
||||
/// pref that `local_state::group_mode` reads).
|
||||
pub fn set_album_group_mode(&self, mode: AlbumGroupMode) {
|
||||
if let Ok(mut m) = self.mode.lock() {
|
||||
*m = mode;
|
||||
}
|
||||
}
|
||||
|
||||
fn ephemeral(&self) -> Option<Arc<dyn EphemeralTracks>> {
|
||||
self.ephemeral.read().ok().and_then(|s| s.clone())
|
||||
}
|
||||
|
||||
fn group_mode(&self) -> AlbumGroupMode {
|
||||
self.mode
|
||||
.lock()
|
||||
.map(|m| *m)
|
||||
.unwrap_or(AlbumGroupMode::Folder)
|
||||
}
|
||||
|
||||
/// True when `id` looks like a local album/folder GROUP KEY.
|
||||
///
|
||||
/// The three shapes, from survey §6.1: an absolute directory path (folder
|
||||
/// mode), `<album>|<album_artist>` (metadata mode, and the ephemeral
|
||||
/// `<album>|||<album_artist>` form), and the literal unknown bucket
|
||||
/// (`library_qt.rs:1053`).
|
||||
fn is_group_key(id: &str) -> bool {
|
||||
id.starts_with('/') || id.contains('|') || id == "__unknown_album__"
|
||||
}
|
||||
|
||||
/// True when `id` is a Plex namespaced row id (`local_plex.rs:37-42`).
|
||||
fn is_plex_namespaced(raw: &RawRef) -> bool {
|
||||
raw.numeric()
|
||||
.map(|n| n & crate::sources::plex::PLEX_TRACK_ID_FLOOR != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Is this a session-scoped ephemeral id? (`local_ephemeral::
|
||||
/// is_ephemeral_id`, local_ephemeral.rs:64-66.)
|
||||
fn is_ephemeral(id: i64) -> bool {
|
||||
id >= EPHEMERAL_ID_FLOOR
|
||||
}
|
||||
|
||||
/// The `library.db` row id (or ephemeral id) a track reference names.
|
||||
///
|
||||
/// Decided on EVIDENCE, in order — the same discipline
|
||||
/// [`crate::PlexSource`] applies to rating keys:
|
||||
///
|
||||
/// 1. an ephemeral-floor numeric id → itself (the session store owns it);
|
||||
/// 2. an OFFLINE row (`badge == Offline`) with a numeric hint → the hint.
|
||||
/// `local_queue_track` puts the Qobuz track id in `QueueTrack.id` and
|
||||
/// the local row id in `source_item_id_hint` for exactly these rows
|
||||
/// (local_playback.rs:55-59, :85-90); today `play_local_file(runtime,
|
||||
/// track.id)` (local_playback.rs:243) looks the row up by the *Qobuz*
|
||||
/// id and misses;
|
||||
/// 3. any other numeric id → itself;
|
||||
/// 4. otherwise → [`SourceError::BadIdShape`].
|
||||
fn row_id(raw: &RawRef) -> Result<i64, SourceError> {
|
||||
if let Some(n) = raw.numeric() {
|
||||
if Self::is_ephemeral(n as i64) {
|
||||
return Ok(n as i64);
|
||||
}
|
||||
}
|
||||
if raw.badge == SourceBadge::Offline {
|
||||
if let Some(h) = raw.hint_str().and_then(|h| h.parse::<i64>().ok()) {
|
||||
return Ok(h);
|
||||
}
|
||||
}
|
||||
if let Some(n) = raw.numeric() {
|
||||
return Ok(n as i64);
|
||||
}
|
||||
Err(SourceError::BadIdShape {
|
||||
by: SourceId::LOCAL,
|
||||
id: raw.id.clone(),
|
||||
why: "a local track id is a numeric library.db row id (or an ephemeral id >= 2^48)",
|
||||
})
|
||||
}
|
||||
|
||||
/// The tracks of one album group key.
|
||||
///
|
||||
/// Moved from `local_albums::fetch_album_tracks_blocking`
|
||||
/// (local_albums.rs:225-241) MINUS its `plex:` fork — that fork dissolves
|
||||
/// into `claim`, and a `plex:` key never reaches this source.
|
||||
fn album_tracks(&self, group_key: &str) -> Vec<LocalTrack> {
|
||||
if let Some(eph) = self.ephemeral() {
|
||||
let rows = eph.album_tracks(group_key);
|
||||
if !rows.is_empty() {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
let mode = self.group_mode();
|
||||
self.with(|db| match mode {
|
||||
AlbumGroupMode::Metadata => {
|
||||
let meta = db.get_album_tracks_metadata(group_key)?;
|
||||
if meta.is_empty() {
|
||||
db.get_album_tracks(group_key)
|
||||
} else {
|
||||
Ok(meta)
|
||||
}
|
||||
}
|
||||
AlbumGroupMode::Folder => db.get_album_tracks(group_key),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One row by id: the ephemeral store for a floor id, else `library.db`.
|
||||
fn track_row(&self, row_id: i64) -> Option<LocalTrack> {
|
||||
if Self::is_ephemeral(row_id) {
|
||||
return self.ephemeral().and_then(|e| e.get_track(row_id));
|
||||
}
|
||||
self.with(|db| db.get_track(row_id)).flatten()
|
||||
}
|
||||
|
||||
fn not_found(&self, item: &MediaRef) -> SourceError {
|
||||
SourceError::NotFound {
|
||||
by: SourceId::LOCAL,
|
||||
kind: item.kind(),
|
||||
id: item.id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Artwork (moved from local_artwork.rs) ────────────────────────────────
|
||||
|
||||
fn gen_shard(src: &str) -> usize {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
src.hash(&mut h);
|
||||
(h.finish() as usize) % GEN_SHARDS
|
||||
}
|
||||
|
||||
fn memoize(&self, src: &str, resolved: &Path) {
|
||||
if let Ok(mut memo) = self.thumbs.write() {
|
||||
if memo.len() >= MEMO_CAP {
|
||||
memo.clear();
|
||||
}
|
||||
memo.insert(src.to_string(), resolved.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail for a local cover IF it costs nothing: the memo, else one
|
||||
/// `stat` on the thumbnail path (which is where a previous run's
|
||||
/// thumbnails live). `None` means "must be generated" — the caller defers
|
||||
/// it to [`Source::thumbnail`].
|
||||
///
|
||||
/// Moved from `local_artwork::cached_thumbnail` (local_artwork.rs:190-217).
|
||||
fn cached_thumbnail(&self, src: &str) -> Option<PathBuf> {
|
||||
// A local cover is very often ALREADY the thumbnail the scanner
|
||||
// generated. Re-hashing its path yields a DIFFERENT filename, so the
|
||||
// cold phase would decode it, Lanczos-resize it to its own size (a null
|
||||
// scale) and re-encode a byte-identical-looking duplicate — that is
|
||||
// most of the wait before the first cover appears.
|
||||
if let Ok(dir) = qbz_library::get_thumbnails_dir() {
|
||||
let p = Path::new(src);
|
||||
if p.starts_with(&dir) && p.is_file() {
|
||||
return Some(p.to_owned());
|
||||
}
|
||||
}
|
||||
if let Ok(memo) = self.thumbs.read() {
|
||||
if let Some(hit) = memo.get(src) {
|
||||
if hit.is_file() {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let thumb = qbz_library::get_thumbnail_path(Path::new(src)).ok()?;
|
||||
if !thumb.is_file() {
|
||||
return None;
|
||||
}
|
||||
self.memoize(src, &thumb);
|
||||
Some(thumb)
|
||||
}
|
||||
|
||||
/// The thumbnail for a local cover, GENERATING it if needed. Falls back to
|
||||
/// the original file when no thumbnail can be produced (unsupported source)
|
||||
/// so the card is not blank; `None` only when the cover itself is gone.
|
||||
///
|
||||
/// Moved from `local_artwork::local_thumbnail` (local_artwork.rs:219-244),
|
||||
/// per-source generation lock included.
|
||||
fn local_thumbnail(&self, src: &str) -> Option<PathBuf> {
|
||||
if let Some(hit) = self.cached_thumbnail(src) {
|
||||
return Some(hit);
|
||||
}
|
||||
let path = Path::new(src);
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
// Serialize same-source generation: the loser of the race re-checks the
|
||||
// memo and returns without decoding anything.
|
||||
let guard = self.gen_locks[Self::gen_shard(src)]
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(hit) = self.cached_thumbnail(src) {
|
||||
drop(guard);
|
||||
return Some(hit);
|
||||
}
|
||||
let resolved =
|
||||
qbz_library::get_or_generate_thumbnail(path).unwrap_or_else(|_| path.to_owned());
|
||||
drop(guard);
|
||||
self.memoize(src, &resolved);
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
/// The cover SOURCE path for an item, memoized. At most ONE indexed query
|
||||
/// on a cold miss.
|
||||
fn cover_source(&self, item: &MediaRef) -> Option<String> {
|
||||
if let Ok(memo) = self.covers.read() {
|
||||
if let Some(hit) = memo.get(item.id()) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
}
|
||||
let found = match item.kind() {
|
||||
ItemKind::Track => item
|
||||
.id()
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.and_then(|id| self.track_row(id))
|
||||
.and_then(|t| t.artwork_path)
|
||||
.filter(|p| !p.is_empty()),
|
||||
ItemKind::Album | ItemKind::Folder => self
|
||||
.album_tracks(item.id())
|
||||
.iter()
|
||||
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty())),
|
||||
_ => None,
|
||||
}?;
|
||||
if let Ok(mut memo) = self.covers.write() {
|
||||
if memo.len() >= MEMO_CAP {
|
||||
memo.clear();
|
||||
}
|
||||
memo.insert(item.id().to_string(), found.clone());
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Seed the cover memo from rows the caller ALREADY has, so the `artwork`
|
||||
/// call inside `meta_of_rows` costs nothing instead of re-querying.
|
||||
fn memoize_cover(&self, item: &MediaRef, rows: &[LocalTrack]) {
|
||||
let Some(path) = rows
|
||||
.iter()
|
||||
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut memo) = self.covers.write() {
|
||||
if memo.len() >= MEMO_CAP {
|
||||
memo.clear();
|
||||
}
|
||||
memo.insert(item.id().to_string(), path);
|
||||
}
|
||||
}
|
||||
|
||||
fn meta_of_rows(&self, item: &MediaRef, rows: &[LocalTrack]) -> ItemMeta {
|
||||
self.memoize_cover(item, rows);
|
||||
let first = &rows[0];
|
||||
let badge = SourceBadge::from_word(first.source.as_deref());
|
||||
ItemMeta {
|
||||
title: if item.kind() == ItemKind::Track {
|
||||
first.title.clone()
|
||||
} else {
|
||||
first.album_group_title.clone()
|
||||
},
|
||||
subtitle: first
|
||||
.album_artist
|
||||
.clone()
|
||||
.filter(|a| !a.is_empty())
|
||||
.unwrap_or_else(|| first.artist.clone()),
|
||||
year: first.year,
|
||||
track_count: Some(rows.len() as u32),
|
||||
duration_secs: Some(rows.iter().map(|t| t.duration_secs).sum()),
|
||||
quality: QualityHint::from_hz(
|
||||
first.bit_depth,
|
||||
first.sample_rate,
|
||||
format_name(&first.format),
|
||||
),
|
||||
art: self.artwork(item, ArtSize::Card),
|
||||
badge,
|
||||
kind_label: item.kind().label(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `AudioFormat` as a `&'static str` for [`QualityHint`]. `Display` allocates;
|
||||
/// the tier check only needs the name.
|
||||
fn format_name(f: &qbz_library::AudioFormat) -> &'static str {
|
||||
use qbz_library::AudioFormat::*;
|
||||
match f {
|
||||
Flac => "FLAC",
|
||||
Alac => "ALAC",
|
||||
Wav => "WAV",
|
||||
Aiff => "AIFF",
|
||||
Ape => "APE",
|
||||
Mp3 => "MP3",
|
||||
Dsd => "DSD",
|
||||
Unknown => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Source for LocalSource {
|
||||
fn id(&self) -> SourceId {
|
||||
SourceId::LOCAL
|
||||
}
|
||||
|
||||
fn claim(&self, raw: &RawRef) -> Option<Result<MediaRef, SourceError>> {
|
||||
// Two shapes another source PROVABLY owns. These are not "claim by
|
||||
// elimination" — they are Plex's declared id forms (survey §6.1), and
|
||||
// excluding them is what keeps the walk single-claim instead of
|
||||
// `Ambiguous`.
|
||||
if raw.id.trim_start().starts_with("plex:") || Self::is_plex_namespaced(raw) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let numeric = raw.numeric();
|
||||
let mine = raw.source == Some(SourceId::LOCAL)
|
||||
|| Self::is_group_key(raw.id.trim())
|
||||
|| numeric
|
||||
.map(|n| Self::is_ephemeral(n as i64))
|
||||
.unwrap_or(false)
|
||||
// A bare numeric with NO source word, that the caller knows is not
|
||||
// Qobuz. The mirror of QobuzSource's `is_local == Some(false)`
|
||||
// rule; `resolve_bulk_qobuz_track_ids` (myqbz_play_qt.rs:246-248)
|
||||
// already splits exactly this way.
|
||||
|| (raw.source.is_none() && raw.is_local == Some(true) && numeric.is_some());
|
||||
if !mine {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = raw.id.trim();
|
||||
if id.is_empty() {
|
||||
return Some(Err(SourceError::BadIdShape {
|
||||
by: SourceId::LOCAL,
|
||||
id: raw.id.clone(),
|
||||
why: "empty id",
|
||||
}));
|
||||
}
|
||||
|
||||
let kind = raw.kind.unwrap_or({
|
||||
if Self::is_group_key(id) {
|
||||
ItemKind::Album
|
||||
} else {
|
||||
ItemKind::Track
|
||||
}
|
||||
});
|
||||
|
||||
Some(match kind {
|
||||
// Group keys and folder paths are opaque and stay verbatim.
|
||||
ItemKind::Album | ItemKind::Folder => {
|
||||
Ok(MediaRef::new(SourceId::LOCAL, kind, id.to_string()))
|
||||
}
|
||||
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.
|
||||
ItemKind::Playlist => Err(SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: ItemKind::Playlist,
|
||||
}),
|
||||
// `local_album_actions.rs:335` routes a local artist by NAME to a
|
||||
// view, never to a resolver.
|
||||
ItemKind::Artist => Err(SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: ItemKind::Artist,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
// 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).
|
||||
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).
|
||||
ItemKind::Track => {
|
||||
let row_id = item.id().parse::<i64>().map_err(|_| SourceError::BadIdShape {
|
||||
by: SourceId::LOCAL,
|
||||
id: item.id().to_string(),
|
||||
why: "a normalised local track id is always numeric",
|
||||
})?;
|
||||
self.track_row(row_id).into_iter().collect()
|
||||
}
|
||||
ItemKind::Playlist | ItemKind::Artist => {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: item.kind(),
|
||||
})
|
||||
}
|
||||
};
|
||||
if rows.is_empty() {
|
||||
return Err(self.not_found(item));
|
||||
}
|
||||
Ok(rows.iter().map(local_queue_track).collect())
|
||||
}
|
||||
|
||||
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
|
||||
let rows: Vec<LocalTrack> = match item.kind() {
|
||||
ItemKind::Album | ItemKind::Folder => self.album_tracks(item.id()),
|
||||
ItemKind::Track => item
|
||||
.id()
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.and_then(|id| self.track_row(id))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
ItemKind::Playlist | ItemKind::Artist => {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: item.kind(),
|
||||
})
|
||||
}
|
||||
};
|
||||
if rows.is_empty() {
|
||||
return Err(self.not_found(item));
|
||||
}
|
||||
Ok(self.meta_of_rows(item, &rows))
|
||||
}
|
||||
|
||||
fn artwork(&self, item: &MediaRef, _size: ArtSize) -> ArtRef {
|
||||
let Some(src) = self.cover_source(item) else {
|
||||
return ArtRef::None;
|
||||
};
|
||||
// CHEAP phase only: the memo, or one `stat` on the thumbnail path.
|
||||
// A miss returns the ORIGINAL, which `thumbnail` upgrades on a worker.
|
||||
match self.cached_thumbnail(&src) {
|
||||
Some(thumb) => ArtRef::File(thumb),
|
||||
None if Path::new(&src).is_file() => ArtRef::File(PathBuf::from(src)),
|
||||
None => ArtRef::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn thumbnail(&self, art: &ArtRef, _size: ArtSize) -> ArtRef {
|
||||
// EXPENSIVE phase: this is where the decode+downscale happens, on the
|
||||
// caller's worker pool (`local_artwork::stream_cold`,
|
||||
// local_artwork.rs:164-188).
|
||||
match art {
|
||||
ArtRef::File(p) => match self.local_thumbnail(&p.to_string_lossy()) {
|
||||
Some(thumb) => ArtRef::File(thumb),
|
||||
None => ArtRef::None,
|
||||
},
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn playback(
|
||||
&self,
|
||||
item: &MediaRef,
|
||||
track: &QueueTrack,
|
||||
) -> Result<PlaybackTicket, SourceError> {
|
||||
// Moved from `local_playback::play_local_file`
|
||||
// (local_playback.rs:119-182), minus the three player calls (`:143`
|
||||
// `play_dsd_file`, `:173` `play_data`, `:155`/`:179` `seek`), which
|
||||
// STAY in qbz-qt: this crate does not link the PROTECTED backend.
|
||||
if item.kind() != ItemKind::Track {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: item.kind(),
|
||||
});
|
||||
}
|
||||
let row_id = item.id().parse::<i64>().map_err(|_| SourceError::BadIdShape {
|
||||
by: SourceId::LOCAL,
|
||||
id: item.id().to_string(),
|
||||
why: "a normalised local track id is always numeric",
|
||||
})?;
|
||||
let row = self.track_row(row_id).ok_or_else(|| self.not_found(item))?;
|
||||
let path = PathBuf::from(&row.file_path);
|
||||
|
||||
// The player keys on the QUEUE's id, which is what every caller passes
|
||||
// today (`play_local_file(runtime, track.id)`, local_playback.rs:243).
|
||||
// It is NOT always the row id — an offline row's queue id is the Qobuz
|
||||
// track id (local_playback.rs:55-59).
|
||||
let play_id = track.id;
|
||||
|
||||
let lower = row.file_path.to_lowercase();
|
||||
if lower.ends_with(".dsf") || lower.ends_with(".dff") {
|
||||
return Ok(PlaybackTicket::DsdFile { path, play_id });
|
||||
}
|
||||
// CUE: every virtual track of a CUE album shares ONE audio file. The
|
||||
// "is that container already loaded?" test reads `player().state`,
|
||||
// which this crate cannot link — so the offset rides along and the
|
||||
// frontend keeps making that decision (local_playback.rs:150-158).
|
||||
let seek_secs = row.cue_start_secs.filter(|s| *s > 0.0);
|
||||
Ok(PlaybackTicket::File {
|
||||
path,
|
||||
play_id,
|
||||
seek_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn bind_user(&self, _uid: u64, dir: &Path) {
|
||||
self.pool.bind(dir);
|
||||
if let Ok(mut m) = self.thumbs.write() {
|
||||
m.clear();
|
||||
}
|
||||
if let Ok(mut m) = self.covers.write() {
|
||||
m.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn teardown(&self) {
|
||||
self.pool.teardown();
|
||||
if let Ok(mut m) = self.thumbs.write() {
|
||||
m.clear();
|
||||
}
|
||||
if let Ok(mut m) = self.covers.write() {
|
||||
m.clear();
|
||||
}
|
||||
if let Ok(mut e) = self.ephemeral.write() {
|
||||
*e = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::id::ItemKind;
|
||||
|
||||
fn claimed(raw: &RawRef) -> Result<MediaRef, SourceError> {
|
||||
LocalSource::new()
|
||||
.claim(raw)
|
||||
.expect("LocalSource should claim this reference")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owns_the_three_group_key_shapes() {
|
||||
// survey §6.1, real rows.
|
||||
for id in [
|
||||
"/home/u/Music/Artist/Album",
|
||||
"HIT ME HARD AND SOFT|Billie Eilish",
|
||||
"__unknown_album__",
|
||||
] {
|
||||
let raw = RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: id.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let m = claimed(&raw).expect("group key is local");
|
||||
assert_eq!(m.source(), SourceId::LOCAL);
|
||||
assert_eq!(m.kind(), ItemKind::Album);
|
||||
assert_eq!(m.id(), id, "a group key is opaque and stays verbatim");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owns_a_bare_numeric_row_id_only_with_evidence() {
|
||||
// Real row: local_tracks.id = 2954 (survey §6.1). With no source word
|
||||
// and no `is_local`, LocalSource must NOT claim — that guess is IC-4.
|
||||
let bare = RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(LocalSource::new().claim(&bare).is_none());
|
||||
|
||||
// With `is_local = Some(true)` (a mixtape Local item), it is ours.
|
||||
let evidenced = RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(claimed(&evidenced).expect("local").id(), "2954");
|
||||
|
||||
// With the source word, likewise.
|
||||
let worded = RawRef::new("local", ItemKind::Track, "2954");
|
||||
assert_eq!(claimed(&worded).expect("local").id(), "2954");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_ids_are_a_local_arm_not_a_source() {
|
||||
// qbz-library/src/ephemeral.rs:43 — EPHEMERAL_ID_FLOOR = 2^48.
|
||||
let raw = RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "281474976710656".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let m = claimed(&raw).expect("ephemeral is local");
|
||||
assert_eq!(m.source(), SourceId::LOCAL);
|
||||
assert_eq!(m.id(), "281474976710656");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_claims_a_plex_shape() {
|
||||
// The album boundary key…
|
||||
let album_key = RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "plex:5677211365378243606".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(LocalSource::new().claim(&album_key).is_none());
|
||||
// …and the namespaced row id (local_plex.rs:277 — real value for
|
||||
// rating key 44440).
|
||||
let namespaced = RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "1099511671736".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(LocalSource::new().claim(&namespaced).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_row_normalises_to_its_library_row_id() {
|
||||
// local_playback.rs:55-59 + :85-90: the queue id is the QOBUZ track id
|
||||
// and the local row id rides in the hint. Today `play_local_file` looks
|
||||
// the row up by the queue id and misses.
|
||||
let raw = RawRef {
|
||||
source: Some(SourceId::LOCAL),
|
||||
badge: SourceBadge::Offline,
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "139578884".into(),
|
||||
is_local: Some(true),
|
||||
hint: Some("2954".into()),
|
||||
};
|
||||
assert_eq!(claimed(&raw).expect("local").id(), "2954");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_row_ignores_a_collection_boundary_hint() {
|
||||
// resolve_collection_tracks (enqueue.rs:52-56) overwrites the hint with
|
||||
// the OWNING ITEM's id — an album group key for a local album. A plain
|
||||
// local row must not mistake it for its own id.
|
||||
let raw = RawRef {
|
||||
source: Some(SourceId::LOCAL),
|
||||
badge: SourceBadge::Local,
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
is_local: Some(true),
|
||||
hint: Some("/home/u/Music/Artist/Album".into()),
|
||||
};
|
||||
assert_eq!(claimed(&raw).expect("local").id(), "2954");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_playlists_are_refused_loudly() {
|
||||
// enqueue.rs:418-423 — the contract, now a named error instead of a
|
||||
// free-text string.
|
||||
let raw = RawRef::new("local", ItemKind::Playlist, "whatever");
|
||||
let err = claimed(&raw).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SourceError::Unsupported {
|
||||
by: SourceId::LOCAL,
|
||||
kind: ItemKind::Playlist
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_numeric_track_id_is_a_named_error() {
|
||||
let raw = RawRef::new("local", ItemKind::Track, "not-a-number");
|
||||
let err = claimed(&raw).unwrap_err();
|
||||
assert!(matches!(err, SourceError::BadIdShape { by: SourceId::LOCAL, .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! The three implementations that exist, and the one obvious slot a fourth
|
||||
//! (Jellyfin, Navidrome) would take: a new file here, a new `pub mod` /
|
||||
//! `pub use` line, a `SourceId` constant, a badge value and one line in
|
||||
//! `SourceRegistry::with_defaults`. Zero edits in any view.
|
||||
|
||||
pub mod local;
|
||||
pub mod plex;
|
||||
mod pool;
|
||||
pub mod qobuz;
|
||||
|
||||
pub use local::{local_queue_track, EphemeralTracks, LocalSource};
|
||||
pub use plex::{is_thumb_path, PlexCreds, PlexSource, PLEX_TRACK_ID_FLOOR};
|
||||
pub use qobuz::QobuzSource;
|
||||
@@ -0,0 +1,687 @@
|
||||
//! Plex — and the home of bug 2.
|
||||
//!
|
||||
//! Plex has FIVE ids for one track (survey §6.2): rating key `44440`, cache row
|
||||
//! id `44440`, namespaced row id `1099511671736`, content-hash album key
|
||||
//! `plex:5677211365378243606`, per-edition album key `plex:album:44439`. Two
|
||||
//! different code paths pick different ones for `QueueTrack.id`:
|
||||
//!
|
||||
//! | 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>` |
|
||||
//!
|
||||
//! 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
|
||||
//! reconstruction of it, and its fallback ("ignore a `plex:` hint, use the
|
||||
//! numeric queue id") is correct for the MyQBZ path and WRONG for the
|
||||
//! LocalLibrary path — it only survives because that path always arrives with a
|
||||
//! good hint. So [`PlexSource::rating_key`] does not guess; it decides on
|
||||
//! evidence, and the table lives here, once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use qbz_models::QueueTrack;
|
||||
use qbz_plex::PlexCachedTrack;
|
||||
|
||||
use crate::art::{ArtRef, ArtSize};
|
||||
use crate::error::SourceError;
|
||||
use crate::id::{ItemKind, MediaRef, RawRef, SourceId};
|
||||
use crate::meta::{ItemMeta, QualityHint, SourceBadge};
|
||||
use crate::playback::PlaybackTicket;
|
||||
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.
|
||||
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;
|
||||
|
||||
/// The Plex credentials, implemented by the frontend over its EXISTING store.
|
||||
///
|
||||
/// Plex credentials live in a per-user SQLite store `qbz-qt` already owns
|
||||
/// (`local_plex.rs:33`, `:48-98`, over `qbz_app::settings::plex::
|
||||
/// PlexSettingsState`). Opening a second copy here would be a second authority
|
||||
/// — and would drag `qbz-app` (hence `qbz-core`/`qbz-player`/`qbz-audio`) into
|
||||
/// this crate's graph, which design §8 forbids. So it is injected: `qbz-qt`'s
|
||||
/// impl is three lines over `local_plex::is_enabled()` / `local_plex::
|
||||
/// settings()`.
|
||||
pub trait PlexCreds: Send + Sync + 'static {
|
||||
/// The master toggle (`local_plex::is_enabled`).
|
||||
fn is_enabled(&self) -> bool;
|
||||
/// `(base_url, token)`, both non-empty, or `None`
|
||||
/// (`local_plex::settings()`, checked exactly as local_playback.rs:194-198
|
||||
/// and local_plex.rs:250-253 check it).
|
||||
fn server(&self) -> Option<(String, String)>;
|
||||
}
|
||||
|
||||
/// The Plex source.
|
||||
pub struct PlexSource {
|
||||
creds: RwLock<Option<Arc<dyn PlexCreds>>>,
|
||||
/// MediaRef id → the RAW `/library/...` thumb path, so a repeat `artwork`
|
||||
/// call costs no cache query.
|
||||
art: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl PlexSource {
|
||||
/// A source with no credentials published yet.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
creds: RwLock::new(None),
|
||||
art: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the frontend's credentials handle. `None` clears it.
|
||||
pub fn set_creds(&self, c: Option<Arc<dyn PlexCreds>>) {
|
||||
if let Ok(mut slot) = self.creds.write() {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
|
||||
/// `(base_url, token)` when Plex is usable right now.
|
||||
///
|
||||
/// Clone-then-drop: the guard is never held across an `.await`. It is a
|
||||
/// `std::sync::RwLock` (this crate has no tokio dep, §8), whose guard is
|
||||
/// `!Send`, so holding it across an await inside an `#[async_trait]` method
|
||||
/// is a COMPILE ERROR rather than a code-review rule.
|
||||
fn server(&self) -> Option<(String, String)> {
|
||||
let creds = { self.creds.read().ok()?.clone()? };
|
||||
if !creds.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
creds
|
||||
.server()
|
||||
.filter(|(base, token)| !base.is_empty() && !token.is_empty())
|
||||
}
|
||||
|
||||
/// Resolve a MASKED cache row id back to its rating key.
|
||||
///
|
||||
/// `PlexCachedTrack.id` is `playback_track_id(rating_key)`
|
||||
/// (`qbz-plex/lib.rs:737-741`): the numeric rating key when it parses, else
|
||||
/// `DefaultHasher(rating_key)`. `map_cached_to_local_track`
|
||||
/// (local_plex.rs:277) then stores `FLOOR | (id & (FLOOR-1))`, so the mask
|
||||
/// recovers `id`, NOT necessarily the rating key.
|
||||
///
|
||||
/// Fast path: the common case is a numeric rating key, where `id` IS the
|
||||
/// key — one indexed PRIMARY KEY lookup CONFIRMS it instead of assuming it.
|
||||
/// Slow path: a non-numeric rating key hashed into `id`, which only a scan
|
||||
/// can invert. That case is today's silent 404 (`plex_rating_key` returns
|
||||
/// the hash and `GET /library/metadata/<hash>` misses).
|
||||
fn resolve_cache_row_id(masked: u64) -> Option<String> {
|
||||
let candidate = masked.to_string();
|
||||
if let Ok(rows) = qbz_plex::plex_cache_get_cached_tracks_by_keys(&[candidate.clone()]) {
|
||||
if rows
|
||||
.iter()
|
||||
.any(|t| t.id & (PLEX_TRACK_ID_FLOOR - 1) == masked)
|
||||
{
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
// `plex_cache_search_tracks("")` matches every row (`WHERE ?1 = '' OR
|
||||
// …`, qbz-plex/lib.rs:1490-1494); the cache is a bounded set.
|
||||
qbz_plex::plex_cache_search_tracks(String::new(), None)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|t| t.id & (PLEX_TRACK_ID_FLOOR - 1) == masked)
|
||||
.map(|t| t.rating_key)
|
||||
}
|
||||
|
||||
/// The rating key a track reference names, decided on EVIDENCE.
|
||||
///
|
||||
/// Replaces `local_playback::plex_rating_key` (local_playback.rs:229-234)
|
||||
/// AND its copy-paste at `local_album_actions.rs:439-450` (deleted, not
|
||||
/// moved). The ordered cases:
|
||||
///
|
||||
/// 1. a hint that is present and NOT `plex:`-prefixed → that IS the rating
|
||||
/// key (the LocalLibrary path, where `local_queue_track` stamps
|
||||
/// `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)`);
|
||||
/// 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
|
||||
/// named error.
|
||||
///
|
||||
/// `resolve` is injected so the pure ladder is unit-testable without a Plex
|
||||
/// cache on disk; [`PlexSource::rating_key`] passes
|
||||
/// [`PlexSource::resolve_cache_row_id`].
|
||||
fn rating_key_with(
|
||||
raw: &RawRef,
|
||||
resolve: impl Fn(u64) -> Option<String>,
|
||||
) -> Result<String, SourceError> {
|
||||
if let Some(hint) = raw.hint_str() {
|
||||
if !hint.starts_with("plex:") {
|
||||
return Ok(hint.to_string());
|
||||
}
|
||||
}
|
||||
match raw.numeric() {
|
||||
Some(n) if n & PLEX_TRACK_ID_FLOOR != 0 => {
|
||||
let masked = n & (PLEX_TRACK_ID_FLOOR - 1);
|
||||
resolve(masked).ok_or(SourceError::NotFound {
|
||||
by: SourceId::PLEX,
|
||||
kind: ItemKind::Track,
|
||||
id: raw.id.clone(),
|
||||
})
|
||||
}
|
||||
Some(n) => Ok(n.to_string()),
|
||||
None => Err(SourceError::BadIdShape {
|
||||
by: SourceId::PLEX,
|
||||
id: raw.id.clone(),
|
||||
why: "not a rating key: non-numeric id with no usable rating-key hint",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn rating_key(raw: &RawRef) -> Result<String, SourceError> {
|
||||
Self::rating_key_with(raw, Self::resolve_cache_row_id)
|
||||
}
|
||||
|
||||
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).
|
||||
qbz_plex::plex_cache_get_album_tracks(item.id().to_string()).unwrap_or_default()
|
||||
}
|
||||
ItemKind::Track => {
|
||||
qbz_plex::plex_cache_get_cached_tracks_by_keys(&[item.id().to_string()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_found(item: &MediaRef) -> SourceError {
|
||||
SourceError::NotFound {
|
||||
by: SourceId::PLEX,
|
||||
kind: item.kind(),
|
||||
id: item.id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn memoize_art(&self, item: &MediaRef, rows: &[PlexCachedTrack]) {
|
||||
let Some(path) = rows
|
||||
.iter()
|
||||
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut memo) = self.art.write() {
|
||||
if memo.len() >= MEMO_CAP {
|
||||
memo.clear();
|
||||
}
|
||||
memo.insert(item.id().to_string(), path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlexSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Plex thumb is a SERVER-RELATIVE path, never a filesystem path.
|
||||
/// Moved from `local_plex::is_thumb_path` (local_plex.rs:239-241).
|
||||
pub fn is_thumb_path(path: &str) -> bool {
|
||||
path.starts_with("/library/") || path.starts_with("/photo/")
|
||||
}
|
||||
|
||||
/// POSITIVE ownership: the source word, one of the two declared `plex:` id
|
||||
/// shapes, or the namespace bit (`local_plex::is_plex_track_id`,
|
||||
/// local_plex.rs:37-42).
|
||||
///
|
||||
/// Split out of `claim` so the predicate is testable without touching
|
||||
/// `plex_cache.db` (the rating-key ladder's case 2 queries it).
|
||||
pub(crate) fn recognises(raw: &RawRef) -> bool {
|
||||
raw.source == Some(SourceId::PLEX)
|
||||
|| raw.id.trim().starts_with("plex:")
|
||||
|| raw
|
||||
.numeric()
|
||||
.map(|n| n & PLEX_TRACK_ID_FLOOR != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Source for PlexSource {
|
||||
fn id(&self) -> SourceId {
|
||||
SourceId::PLEX
|
||||
}
|
||||
|
||||
fn claim(&self, raw: &RawRef) -> Option<Result<MediaRef, SourceError>> {
|
||||
if !recognises(raw) {
|
||||
return None;
|
||||
}
|
||||
let id = raw.id.trim();
|
||||
let prefixed = id.starts_with("plex:");
|
||||
|
||||
let kind = raw.kind.unwrap_or(if prefixed {
|
||||
ItemKind::Album
|
||||
} else {
|
||||
ItemKind::Track
|
||||
});
|
||||
|
||||
Some(match kind {
|
||||
ItemKind::Album => {
|
||||
if id.starts_with("plex:album:") {
|
||||
// survey IC-2, second face: `local_plex.rs:283-291` derives
|
||||
// the per-EDITION key while `plex_cache_get_album_tracks`
|
||||
// (qbz-plex/lib.rs:1402-1410) only queries `album_key =
|
||||
// plex:<hash>`. `local_albums.rs:226-228` forwards it
|
||||
// verbatim and the album page comes back empty. Say so.
|
||||
Err(SourceError::BadIdShape {
|
||||
by: SourceId::PLEX,
|
||||
id: id.to_string(),
|
||||
why: "plex:album:<parent rating key> is a per-edition key; the cache is keyed by the content-hash album key",
|
||||
})
|
||||
} else if prefixed {
|
||||
Ok(MediaRef::new(SourceId::PLEX, ItemKind::Album, id))
|
||||
} else {
|
||||
Err(SourceError::BadIdShape {
|
||||
by: SourceId::PLEX,
|
||||
id: id.to_string(),
|
||||
why: "a plex album id is the content-hash key plex:<hash>",
|
||||
})
|
||||
}
|
||||
}
|
||||
ItemKind::Track => {
|
||||
if prefixed {
|
||||
// BUG 2, at the moment of the mistake instead of two layers
|
||||
// down as a 404 (`GET /library/metadata/plex:<hash>`).
|
||||
Err(SourceError::BadIdShape {
|
||||
by: SourceId::PLEX,
|
||||
id: id.to_string(),
|
||||
why: "plex:<hash> is an album boundary key, not a rating key",
|
||||
})
|
||||
} else {
|
||||
Self::rating_key(raw)
|
||||
.map(|key| MediaRef::new(SourceId::PLEX, ItemKind::Track, key))
|
||||
}
|
||||
}
|
||||
other => Err(SourceError::Unsupported {
|
||||
by: SourceId::PLEX,
|
||||
kind: other,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
|
||||
// Cache reads only — no `.await` in this body.
|
||||
let rows = Self::cached_rows(item);
|
||||
if rows.is_empty() {
|
||||
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
|
||||
// `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())
|
||||
}
|
||||
|
||||
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
|
||||
let rows = Self::cached_rows(item);
|
||||
let Some(first) = rows.first() else {
|
||||
return Err(Self::not_found(item));
|
||||
};
|
||||
self.memoize_art(item, &rows);
|
||||
Ok(ItemMeta {
|
||||
title: if item.kind() == ItemKind::Track {
|
||||
first.title.clone()
|
||||
} else {
|
||||
first.album.clone()
|
||||
},
|
||||
subtitle: first.artist.clone(),
|
||||
year: None,
|
||||
track_count: Some(rows.len() as u32),
|
||||
duration_secs: Some(rows.iter().map(|t| t.duration_secs).sum()),
|
||||
quality: QualityHint::from_hz(
|
||||
first.bit_depth,
|
||||
first.sample_rate as f64,
|
||||
// The cache stores a container name; the tier only needs to
|
||||
// know whether it is MP3 (local_rows.rs:181-183).
|
||||
if first.format.eq_ignore_ascii_case("mp3") {
|
||||
"MP3"
|
||||
} else {
|
||||
"FLAC"
|
||||
},
|
||||
),
|
||||
art: self.artwork(item, ArtSize::Card),
|
||||
badge: SourceBadge::Plex,
|
||||
kind_label: item.kind().label(),
|
||||
})
|
||||
}
|
||||
|
||||
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
|
||||
// Moved from `local_plex::thumb_url` (local_plex.rs:243-252) + the
|
||||
// Plex arm of `artwork_qt::classify` (artwork_qt.rs:167-176).
|
||||
let path = {
|
||||
let memo = self.art.read().ok().and_then(|m| m.get(item.id()).cloned());
|
||||
match memo {
|
||||
Some(p) => Some(p),
|
||||
None => {
|
||||
let rows = Self::cached_rows(item);
|
||||
self.memoize_art(item, &rows);
|
||||
rows.iter()
|
||||
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(path) = path.filter(|p| is_thumb_path(p)) else {
|
||||
return ArtRef::None;
|
||||
};
|
||||
let Some((base, token)) = self.server() else {
|
||||
// Distinct from `None` so the miss is logged for what it is —
|
||||
// `ArtUrl::PlexUnconfigured`'s reason for existing.
|
||||
return ArtRef::Unavailable("plex is not connected");
|
||||
};
|
||||
ArtRef::Fetch {
|
||||
url: qbz_models::plex_thumb_url(&base, &token, &path, size.plex_px()),
|
||||
// The RAW `/library/...` path is the stable memo key; the tokenized
|
||||
// url is rebuilt every pass (artwork_qt.rs:231-238).
|
||||
cache_key: path,
|
||||
}
|
||||
}
|
||||
|
||||
async fn playback(
|
||||
&self,
|
||||
item: &MediaRef,
|
||||
track: &QueueTrack,
|
||||
) -> Result<PlaybackTicket, SourceError> {
|
||||
// Moved from `local_playback::play_plex_track`
|
||||
// (local_playback.rs:193-212), minus the `play_data` at `:201`, which
|
||||
// STAYS in qbz-qt. `item.id()` IS the rating key by construction — this
|
||||
// function does not re-derive anything, which is why no caller can
|
||||
// smuggle a `plex:<hash>` in here.
|
||||
if item.kind() != ItemKind::Track {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::PLEX,
|
||||
kind: item.kind(),
|
||||
});
|
||||
}
|
||||
let (base, token) = self.server().ok_or(SourceError::NotConfigured {
|
||||
by: SourceId::PLEX,
|
||||
why: "no Plex credentials configured",
|
||||
})?;
|
||||
let rating_key = item.id().to_string();
|
||||
match qbz_plex::plex_resolve_track_media(base, token, rating_key).await {
|
||||
// The ORIGINAL bytes — bit-perfect, no transcode is requested.
|
||||
Ok(media) => Ok(PlaybackTicket::Bytes {
|
||||
bytes: media.bytes,
|
||||
play_id: track.id,
|
||||
}),
|
||||
Err(e) => Err(SourceError::Backend {
|
||||
by: SourceId::PLEX,
|
||||
msg: e,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_user(&self, _uid: u64, _dir: &std::path::Path) {
|
||||
// The creds store is re-bound by the frontend and re-published through
|
||||
// `set_creds`; the art memo belongs to the previous account's server.
|
||||
if let Ok(mut m) = self.art.write() {
|
||||
m.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn teardown(&self) {
|
||||
self.set_creds(None);
|
||||
if let Ok(mut m) = self.art.write() {
|
||||
m.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::meta::SourceBadge;
|
||||
|
||||
/// The stub stands in for the Plex cache, so the pure ladder is testable
|
||||
/// with no `plex_cache.db` on disk.
|
||||
fn no_cache(_masked: u64) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn track_ref(id: &str, hint: Option<&str>) -> RawRef {
|
||||
RawRef {
|
||||
source: Some(SourceId::PLEX),
|
||||
badge: SourceBadge::Plex,
|
||||
kind: Some(ItemKind::Track),
|
||||
id: id.into(),
|
||||
is_local: Some(true),
|
||||
hint: hint.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
// ── The three cases local_playback.rs:410-444 already pinned ────────────
|
||||
|
||||
/// LocalLibrary path: the hint IS the raw rating key (`local_queue_track`
|
||||
/// stamps `file_path`, which for a Plex row is the server key).
|
||||
#[test]
|
||||
fn raw_hint_is_used_verbatim() {
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(&track_ref("999", Some("12345")), no_cache).unwrap(),
|
||||
"12345"
|
||||
);
|
||||
// Non-numeric server keys are legal and must survive untouched.
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(
|
||||
&track_ref("999", Some("/library/metadata/771")),
|
||||
no_cache
|
||||
)
|
||||
.unwrap(),
|
||||
"/library/metadata/771"
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG 2: the MyQBZ collections path stamps the ALBUM boundary key
|
||||
/// (`qbz_plex::plex_album_key` → `plex:<hash>`, enqueue.rs:52-56). 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() {
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(&track_ref("771", Some("plex:deadbeef")), no_cache)
|
||||
.unwrap(),
|
||||
"771"
|
||||
);
|
||||
// Real album key from the on-disk DB (survey §6.1).
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(
|
||||
&track_ref("44440", Some("plex:5677211365378243606")),
|
||||
no_cache
|
||||
)
|
||||
.unwrap(),
|
||||
"44440"
|
||||
);
|
||||
// Prefix test is on the LEADING bytes only, exactly like `starts_with`.
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(&track_ref("42", Some("plex:")), no_cache).unwrap(),
|
||||
"42"
|
||||
);
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(&track_ref("42", Some("77plex:1")), no_cache).unwrap(),
|
||||
"77plex:1"
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing hint falls back to the bare numeric id (the MyQBZ shape,
|
||||
/// `enqueue.rs:480`).
|
||||
#[test]
|
||||
fn missing_hint_falls_back_to_queue_id() {
|
||||
assert_eq!(
|
||||
PlexSource::rating_key_with(&track_ref("771", None), no_cache).unwrap(),
|
||||
"771"
|
||||
);
|
||||
}
|
||||
|
||||
// ── New coverage the old tests never had ────────────────────────────────
|
||||
|
||||
/// Case 2: the LocalLibrary namespaced id is RESOLVED, never stringified.
|
||||
///
|
||||
/// The pair is DERIVED from the floor rather than transcribed. The survey's
|
||||
/// id table asserted that `1099511671736` was rating key `44440`; it is
|
||||
/// not — `PLEX_TRACK_ID_FLOOR` is `1 << 40` = 1_099_511_627_776, so that id
|
||||
/// unmasks to 43_960 and 44_440 namespaces to 1_099_511_672_216. Hardcoding
|
||||
/// a transcribed pair is how the wrong one got here in the first place, so
|
||||
/// the test computes it.
|
||||
#[test]
|
||||
fn namespaced_id_is_resolved_through_the_cache() {
|
||||
const FLOOR: u64 = 1 << 40;
|
||||
let rating_key: u64 = 44_440;
|
||||
let namespaced = (rating_key + FLOOR).to_string();
|
||||
let raw = track_ref(&namespaced, None);
|
||||
let resolved =
|
||||
PlexSource::rating_key_with(&raw, |masked| Some(format!("rk-{masked}"))).unwrap();
|
||||
assert_eq!(resolved, format!("rk-{rating_key}"));
|
||||
// And a cache miss is a named NotFound, not a silent wrong key.
|
||||
let err = PlexSource::rating_key_with(&raw, no_cache).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SourceError::NotFound {
|
||||
by: SourceId::PLEX,
|
||||
kind: ItemKind::Track,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// Case 4: a non-numeric rating key with no hint. `playback_track_id`
|
||||
/// (qbz-plex/lib.rs:737-741) hashes those, so today it 404s silently.
|
||||
#[test]
|
||||
fn non_numeric_id_without_a_hint_is_a_named_error() {
|
||||
let err = PlexSource::rating_key_with(&track_ref("abc-123", None), no_cache).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SourceError::BadIdShape {
|
||||
by: SourceId::PLEX,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// ── claim ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn album_boundary_key_is_rejected_in_track_position() {
|
||||
// The shape bug 2 is named after, arriving as the ID rather than as a
|
||||
// hint (a mis-stored mixtape Track item).
|
||||
let raw = RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "plex:5677211365378243606".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let err = PlexSource::new().claim(&raw).unwrap().unwrap_err();
|
||||
match err {
|
||||
SourceError::BadIdShape { by, why, .. } => {
|
||||
assert_eq!(by, SourceId::PLEX);
|
||||
assert!(why.contains("album boundary key"));
|
||||
}
|
||||
other => panic!("expected BadIdShape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_boundary_key_is_owned_in_album_position() {
|
||||
let raw = RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "plex:5677211365378243606".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let m = PlexSource::new().claim(&raw).unwrap().unwrap();
|
||||
assert_eq!(m.source(), SourceId::PLEX);
|
||||
assert_eq!(m.kind(), ItemKind::Album);
|
||||
assert_eq!(m.id(), "plex:5677211365378243606");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_edition_key_is_rejected_instead_of_returning_nothing() {
|
||||
// survey IC-2, second face — real value `plex:album:44439`.
|
||||
let raw = RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "plex:album:44439".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let err = PlexSource::new().claim(&raw).unwrap().unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SourceError::BadIdShape {
|
||||
by: SourceId::PLEX,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_claim_what_it_does_not_own() {
|
||||
let plex = PlexSource::new();
|
||||
// A local group key.
|
||||
assert!(plex
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "HIT ME HARD AND SOFT|Billie Eilish".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.is_none());
|
||||
// A local row id (real row, survey §6.1) — no source word, no bit.
|
||||
assert!(plex
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.is_none());
|
||||
// A Qobuz album id.
|
||||
assert!(plex
|
||||
.claim(&RawRef::new("qobuz", ItemKind::Album, "0060254702523"))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_bit_is_enough_to_recognise_without_a_source_word() {
|
||||
// survey IC-3: a mixtape Plex TRACK item carries the namespaced id as a
|
||||
// STRING and no usable source word. Plex must still recognise it, or it
|
||||
// falls through to LocalSource and `db.get_track` misses.
|
||||
//
|
||||
// The predicate is asserted rather than `claim`, because claiming a
|
||||
// namespaced id runs the cache resolve (case 2) and a unit test must
|
||||
// not touch `plex_cache.db`.
|
||||
assert!(recognises(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "1099511671736".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(!recognises(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
}));
|
||||
// An ephemeral id (2^48) has bit 40 CLEAR, so it is never mistaken for
|
||||
// a Plex row (survey §6.3).
|
||||
assert!(!recognises(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "281474976710656".into(),
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! One handle per source, instead of one handle per CALL.
|
||||
//!
|
||||
//! Today (bug 1): `LibraryDatabase::open(&path)` runs on **every** call, in
|
||||
//! **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.
|
||||
//!
|
||||
//! `open()` is not free: it runs the migrations and the pragmas.
|
||||
//!
|
||||
//! # Why there is no guard type
|
||||
//!
|
||||
//! `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
|
||||
//! `checkout() -> Guard` anywhere, public or crate-private. A future author
|
||||
//! cannot write the bug.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_library::{LibraryDatabase, LibraryError};
|
||||
|
||||
/// How many idle handles are RETAINED.
|
||||
///
|
||||
/// It is NOT a concurrency cap: [`DbPool::with`] opens a fresh handle whenever
|
||||
/// the free list is empty, so N truly concurrent callers still get N handles —
|
||||
/// which is what makes the change no-worse than today in every case. Four is
|
||||
/// what a burst leaves warm.
|
||||
const POOL: usize = 4;
|
||||
|
||||
/// The per-user `library.db` handle set.
|
||||
pub(crate) struct DbPool {
|
||||
/// The bound user's `library.db`. `None` = never bound this session (see
|
||||
/// the unbound fallback), or torn down.
|
||||
path: Mutex<Option<PathBuf>>,
|
||||
/// Free list of live handles, capped at [`POOL`]. `(generation, handle)`.
|
||||
idle: Mutex<Vec<(u64, LibraryDatabase)>>,
|
||||
/// Bumped on every rebind/teardown. A handle whose generation is stale is
|
||||
/// DROPPED on check-in and never re-served — the anti-leak that makes it
|
||||
/// impossible for user A's connection to answer user B's query. Correctness
|
||||
/// does not depend on anybody remembering to call `teardown`.
|
||||
generation: AtomicU64,
|
||||
/// Path computation for the UNBOUND window only.
|
||||
///
|
||||
/// Today `db_path()` resolves the path per call from
|
||||
/// `UserDataPaths::load_last_user_id()` (local_state.rs:28-37,
|
||||
/// library_db_qt.rs:24-33), so `with_db` works at *any* moment, including
|
||||
/// before the auth path has bound anything. A pool that only knows the
|
||||
/// directory `bind_user` gave it would return `None` for every one of those
|
||||
/// calls — a silent regression, not a neutral change.
|
||||
///
|
||||
/// It is INJECTED rather than computed here because `UserDataPaths` lives
|
||||
/// in `qbz-app`, and depending on `qbz-app` would drag
|
||||
/// `qbz-core`/`qbz-player`/`qbz-audio` into this crate's graph (design §8
|
||||
/// forbids it — the PROTECTED backend must not be linkable from here).
|
||||
///
|
||||
/// The fallback exists so a HALF-APPLIED stage 1 degrades to today's
|
||||
/// behaviour instead of to "no local library". It is removed once every
|
||||
/// entry point provably runs after `bind_user`.
|
||||
fallback: Mutex<Option<Box<dyn Fn() -> Option<PathBuf> + Send + Sync>>>,
|
||||
/// One `warn!` per process for the unbound window, not one per call.
|
||||
warned_unbound: AtomicBool,
|
||||
}
|
||||
|
||||
impl DbPool {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
path: Mutex::new(None),
|
||||
idle: Mutex::new(Vec::new()),
|
||||
generation: AtomicU64::new(0),
|
||||
fallback: Mutex::new(None),
|
||||
warned_unbound: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the unbound-window path computation (see [`DbPool::fallback`]).
|
||||
pub(crate) fn set_unbound_fallback(
|
||||
&self,
|
||||
f: Option<Box<dyn Fn() -> Option<PathBuf> + Send + Sync>>,
|
||||
) {
|
||||
if let Ok(mut slot) = self.fallback.lock() {
|
||||
*slot = f;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind (or re-bind) to a user directory. Bumps the generation and drains
|
||||
/// the free list, so no outstanding handle can serve the new user.
|
||||
pub(crate) fn bind(&self, dir: &Path) {
|
||||
if let Ok(mut p) = self.path.lock() {
|
||||
*p = Some(dir.join("library.db"));
|
||||
}
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
/// Drop every handle and forget the binding.
|
||||
///
|
||||
/// NOTE this is a behaviour FIX, not a neutral change: today a post-logout
|
||||
/// `with_db` still resolves through `load_last_user_id()` and can return
|
||||
/// the PREVIOUS account's rows. After teardown the pool refuses reads, so a
|
||||
/// view that used to render stale data after logout now renders empty.
|
||||
pub(crate) fn teardown(&self) {
|
||||
if let Ok(mut p) = self.path.lock() {
|
||||
*p = None;
|
||||
}
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
fn invalidate(&self) {
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
if let Ok(mut idle) = self.idle.lock() {
|
||||
idle.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// READ semantics: `None` when there is no session or the file does not
|
||||
/// exist. Never creates.
|
||||
///
|
||||
/// Moved from `local_state::with_db` (local_state.rs:39-61) — the SIGNATURE
|
||||
/// of that function does not change, only its body, so its 48 call sites
|
||||
/// are untouched.
|
||||
pub(crate) fn with<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
|
||||
) -> Option<R> {
|
||||
self.run(false, f)
|
||||
}
|
||||
|
||||
/// WRITE semantics: creates the directory and the file when absent.
|
||||
///
|
||||
/// Moved from `library_db_qt::with_db(create = true)`
|
||||
/// (library_db_qt.rs:50-76). Both semantics are kept deliberately:
|
||||
/// `library_db_qt.rs:41-49` records why — a brand-new account has no
|
||||
/// `library.db`, the read-only accessor returns `None`, and through it the
|
||||
/// mixtape migrations could never run, so the user could never create a
|
||||
/// collection. Collapsing the two accessors would resurrect that.
|
||||
pub(crate) fn with_creating<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
|
||||
) -> Option<R> {
|
||||
self.run(true, f)
|
||||
}
|
||||
|
||||
/// Check-out → run `f` with NO lock held → check-in.
|
||||
///
|
||||
/// Holding `idle` across `f` would (a) serialise every reader and (b)
|
||||
/// **self-deadlock** on any re-entrant `with_db(|db| … with_db(…) …)` — a
|
||||
/// shape today's open-per-call tolerates for free, so a design that breaks
|
||||
/// it is not behaviour-neutral. There is no `R: Send` bound: nothing here
|
||||
/// crosses a thread.
|
||||
fn run<R>(
|
||||
&self,
|
||||
create: bool,
|
||||
f: impl FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
|
||||
) -> Option<R> {
|
||||
let (generation, db) = self.checkout(create)?;
|
||||
let out = match f(&db) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
// NOTE: the two accessors this replaces logged DIFFERENT
|
||||
// strings (local_state.rs:49/57 "local library …" vs
|
||||
// library_db_qt.rs:65/72 "library.db …"). One pool emits one
|
||||
// set; anything grepping the old text must be updated once.
|
||||
log::error!("[qbz-source] library.db op failed: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
self.checkin(generation, db);
|
||||
out
|
||||
}
|
||||
|
||||
/// Pop a live handle, or open a fresh one. The `idle` lock is released
|
||||
/// before this returns.
|
||||
fn checkout(&self, create: bool) -> Option<(u64, LibraryDatabase)> {
|
||||
let generation = self.generation.load(Ordering::Acquire);
|
||||
if let Ok(mut idle) = self.idle.lock() {
|
||||
while let Some((g, db)) = idle.pop() {
|
||||
if g == generation {
|
||||
return Some((g, db));
|
||||
}
|
||||
// Stale generation: drop it here rather than serve it.
|
||||
drop(db);
|
||||
}
|
||||
}
|
||||
let path = self.resolve_path()?;
|
||||
if create {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
} else if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
match LibraryDatabase::open(&path) {
|
||||
Ok(db) => Some((generation, db)),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-source] library.db open failed ({}): {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the handle, dropping it if it is stale or the free list is full.
|
||||
fn checkin(&self, generation: u64, db: LibraryDatabase) {
|
||||
if generation != self.generation.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut idle) = self.idle.lock() {
|
||||
if idle.len() < POOL {
|
||||
idle.push((generation, db));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self) -> Option<PathBuf> {
|
||||
if let Ok(p) = self.path.lock() {
|
||||
if let Some(path) = p.as_ref() {
|
||||
return Some(path.clone());
|
||||
}
|
||||
}
|
||||
// Unbound window only: never bound this session. After a teardown the
|
||||
// generation is non-zero and the pool refuses reads (see `teardown`).
|
||||
if self.generation.load(Ordering::Acquire) != 0 {
|
||||
return None;
|
||||
}
|
||||
let path = {
|
||||
let slot = self.fallback.lock().ok()?;
|
||||
(slot.as_ref()?)()
|
||||
};
|
||||
if path.is_some() && !self.warned_unbound.swap(true, Ordering::AcqRel) {
|
||||
log::warn!(
|
||||
"[qbz-source] library.db accessed before bind_user; using the unbound fallback path"
|
||||
);
|
||||
}
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DbPool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
//! The Qobuz streaming catalog.
|
||||
//!
|
||||
//! Track/meta resolution REUSES `qbz-mixtape`'s existing async free fns
|
||||
//! (`resolve_qobuz_album` / `_track` / `_playlist`, enqueue.rs:218-327) rather
|
||||
//! than re-deriving the mappers; that crate is not modified and stays a leaf.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::art::{ArtRef, ArtSize};
|
||||
use crate::error::SourceError;
|
||||
use crate::id::{ItemKind, MediaRef, RawRef, SourceId};
|
||||
use crate::meta::{ItemMeta, QualityHint, SourceBadge};
|
||||
use crate::playback::PlaybackTicket;
|
||||
use crate::source::Source;
|
||||
|
||||
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP` (artwork_qt.rs:82).
|
||||
const MEMO_CAP: usize = 8192;
|
||||
|
||||
/// The Qobuz source.
|
||||
pub struct QobuzSource {
|
||||
/// `std::sync::RwLock`, NOT tokio's — this crate has no tokio dep (§8).
|
||||
/// Its guard is `!Send`, so `#[async_trait]`'s `Send` future bound makes
|
||||
/// "hold the guard across an await" a COMPILE ERROR rather than a rule.
|
||||
/// Every accessor clones the client inside a block and drops the guard
|
||||
/// before the first `.await`: `myqbz_play_qt.rs:117-130` records that
|
||||
/// holding the guard instead DEADLOCKS against the playback path.
|
||||
client: RwLock<Option<qbz_qobuz::QobuzClient>>,
|
||||
/// The uid the client belongs to. A `bind_user` with a different uid clears
|
||||
/// the client even if nobody remembered to call `teardown` — the leak class
|
||||
/// this port already shipped once (auth_qt.rs:193-197).
|
||||
owner: Mutex<Option<u64>>,
|
||||
/// MediaRef id → its CDN cover url, filled by `tracks`/`meta`.
|
||||
///
|
||||
/// `artwork` is the CHEAP phase and must not hit the network, and a Qobuz
|
||||
/// id carries no cover on its own — so a cold id answers
|
||||
/// `Unavailable` rather than blocking a row on an HTTP round-trip.
|
||||
art: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl QobuzSource {
|
||||
/// A source with no client published yet.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: RwLock::new(None),
|
||||
owner: Mutex::new(None),
|
||||
art: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the shared Qobuz client. `None` clears it.
|
||||
///
|
||||
/// MUST be called AFTER `core.set_session` (auth_qt.rs:145, :284 and the
|
||||
/// offline entry), never after `try_init_api`: `qbz-core` replaces its own
|
||||
/// client inside `try_init_api` (`qbz-core/src/core.rs:384`), which
|
||||
/// `ensure_api_initialized` calls at auth_qt.rs:83 and :277 — i.e. BEFORE
|
||||
/// `set_session` on both the login and the silent-restore paths. A
|
||||
/// `QobuzSource` holding a client `qbz-core` has since replaced fails
|
||||
/// SILENTLY.
|
||||
pub fn set_client(&self, c: Option<qbz_qobuz::QobuzClient>) {
|
||||
if let Ok(mut slot) = self.client.write() {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
|
||||
/// A CLONE of the client — one clone per call site, replacing the one clone
|
||||
/// per call the frontend does today. The guard is dropped before the
|
||||
/// caller's first `.await`.
|
||||
fn client(&self) -> Result<qbz_qobuz::QobuzClient, SourceError> {
|
||||
let c = { self.client.read().ok().and_then(|g| g.clone()) };
|
||||
c.ok_or(SourceError::NotConfigured {
|
||||
by: SourceId::QOBUZ,
|
||||
why: "no client",
|
||||
})
|
||||
}
|
||||
|
||||
fn backend(msg: String) -> SourceError {
|
||||
SourceError::Backend {
|
||||
by: SourceId::QOBUZ,
|
||||
msg,
|
||||
}
|
||||
}
|
||||
|
||||
fn memoize_art(&self, id: &str, url: Option<String>) {
|
||||
let Some(url) = url.filter(|u| !u.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut memo) = self.art.write() {
|
||||
if memo.len() >= MEMO_CAP {
|
||||
memo.clear();
|
||||
}
|
||||
memo.insert(id.to_string(), url);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-downscale a Qobuz cover url to a per-cell target size.
|
||||
///
|
||||
/// Moved from `myqbz_qt::small_qobuz_url` (myqbz_qt.rs:376-401), whose doc
|
||||
/// comment says in capitals "CALLERS MUST GATE THIS ON
|
||||
/// `AlbumSource::Qobuz`" — a caller-enforced invariant, and the exact
|
||||
/// anti-pattern the seam removes. Here it lives INSIDE the Qobuz source, so
|
||||
/// there is no caller left to forget the gate.
|
||||
fn small_url(url: &str, target: u32) -> String {
|
||||
if url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
const TOKENS: [&str; 8] = [
|
||||
"_50.jpg", "_100.jpg", "_150.jpg", "_230.jpg", "_300.jpg", "_600.jpg", "_max.jpg",
|
||||
"_org.jpg",
|
||||
];
|
||||
let lower = url.to_lowercase();
|
||||
for tok in TOKENS {
|
||||
if let Some(pos) = lower.rfind(tok) {
|
||||
let mut out = String::with_capacity(url.len());
|
||||
out.push_str(&url[..pos]);
|
||||
out.push_str(&format!("_{target}.jpg"));
|
||||
out.push_str(&url[pos + tok.len()..]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
fn numeric_id(item: &MediaRef) -> Result<u64, SourceError> {
|
||||
item.id().parse::<u64>().map_err(|_| SourceError::BadIdShape {
|
||||
by: SourceId::QOBUZ,
|
||||
id: item.id().to_string(),
|
||||
why: "qobuz track / playlist / artist ids are numeric",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QobuzSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an ISO `YYYY-MM-DD` release date's year.
|
||||
fn year_of(date: Option<&String>) -> Option<u32> {
|
||||
date?.get(..4)?.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Source for QobuzSource {
|
||||
fn id(&self) -> SourceId {
|
||||
SourceId::QOBUZ
|
||||
}
|
||||
|
||||
fn claim(&self, raw: &RawRef) -> Option<Result<MediaRef, SourceError>> {
|
||||
// POSITIVE ownership only. The second arm is the rule
|
||||
// `resolve_bulk_qobuz_track_ids` (myqbz_play_qt.rs:246-248) already
|
||||
// uses, and the only correct one for an unlabelled queue row. There is
|
||||
// deliberately NO "everything else is Qobuz" fallback — that is
|
||||
// `myqbz_add_qt::source_from_str`'s `_ => Qobuz` (survey IC-6), which
|
||||
// this seam exists to delete.
|
||||
if !(raw.source == Some(SourceId::QOBUZ)
|
||||
|| (raw.source.is_none() && raw.is_local == Some(false)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let id = raw.id.trim();
|
||||
if id.is_empty() {
|
||||
return Some(Err(SourceError::BadIdShape {
|
||||
by: SourceId::QOBUZ,
|
||||
id: raw.id.clone(),
|
||||
why: "empty id",
|
||||
}));
|
||||
}
|
||||
let kind = raw.kind.unwrap_or(ItemKind::Album);
|
||||
Some(match kind {
|
||||
// A Qobuz ALBUM id is opaque and NOT always numeric — a barcode
|
||||
// (`0060254702523`) or a base36-ish slug (`e94no900otyrz`), survey
|
||||
// §6.1. It stays verbatim.
|
||||
ItemKind::Album => Ok(MediaRef::new(SourceId::QOBUZ, ItemKind::Album, id)),
|
||||
ItemKind::Track | ItemKind::Playlist | ItemKind::Artist => {
|
||||
if id.parse::<u64>().is_ok() {
|
||||
Ok(MediaRef::new(SourceId::QOBUZ, kind, id))
|
||||
} else {
|
||||
// enqueue.rs:196-207 parses both and errors on failure;
|
||||
// here it is a named error at the claim point instead.
|
||||
Err(SourceError::BadIdShape {
|
||||
by: SourceId::QOBUZ,
|
||||
id: id.to_string(),
|
||||
why: "qobuz track / playlist / artist ids are numeric",
|
||||
})
|
||||
}
|
||||
}
|
||||
ItemKind::Folder => Err(SourceError::Unsupported {
|
||||
by: SourceId::QOBUZ,
|
||||
kind: ItemKind::Folder,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
|
||||
let client = self.client()?;
|
||||
// The mappers stay in `qbz-mixtape`; this is the routing only. Their
|
||||
// "album X has 0 tracks" case comes back as an Err STRING, so it lands
|
||||
// in `Backend` rather than `NotFound` — converting it would mean
|
||||
// sniffing the message, which is the class of thing this crate exists
|
||||
// to stop doing.
|
||||
let tracks = match item.kind() {
|
||||
ItemKind::Album => qbz_mixtape::enqueue::resolve_qobuz_album(&client, item.id())
|
||||
.await
|
||||
.map_err(Self::backend)?,
|
||||
ItemKind::Track => {
|
||||
qbz_mixtape::enqueue::resolve_qobuz_track(&client, Self::numeric_id(item)?)
|
||||
.await
|
||||
.map_err(Self::backend)?
|
||||
}
|
||||
ItemKind::Playlist => {
|
||||
qbz_mixtape::enqueue::resolve_qobuz_playlist(&client, Self::numeric_id(item)?)
|
||||
.await
|
||||
.map_err(Self::backend)?
|
||||
}
|
||||
kind => {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::QOBUZ,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
};
|
||||
self.memoize_art(
|
||||
item.id(),
|
||||
tracks.first().and_then(|t| t.artwork_url.clone()),
|
||||
);
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
|
||||
let client = self.client()?;
|
||||
let meta = match item.kind() {
|
||||
ItemKind::Album => {
|
||||
let a = client
|
||||
.get_album(item.id())
|
||||
.await
|
||||
.map_err(|e| Self::backend(e.to_string()))?;
|
||||
let m = ItemMeta {
|
||||
title: a.title.clone(),
|
||||
subtitle: a.artist.name.clone(),
|
||||
year: year_of(a.release_date_original.as_ref()),
|
||||
track_count: a.tracks_count.or(a.track_count),
|
||||
duration_secs: a.duration.map(|d| d as u64),
|
||||
quality: QualityHint {
|
||||
bit_depth: a.maximum_bit_depth,
|
||||
sample_rate_khz: a.maximum_sampling_rate,
|
||||
format: "FLAC",
|
||||
},
|
||||
art: ArtRef::None,
|
||||
badge: SourceBadge::Qobuz,
|
||||
// The V2 wire carries the real release type; fall back to
|
||||
// the kind key.
|
||||
kind_label: match a.release_type.as_deref() {
|
||||
Some("ep") => "ep",
|
||||
Some("single") => "single",
|
||||
_ => "album",
|
||||
},
|
||||
};
|
||||
with_art(m, self, item, a.image.best().cloned())
|
||||
}
|
||||
ItemKind::Track => {
|
||||
let t = client
|
||||
.get_track(Self::numeric_id(item)?)
|
||||
.await
|
||||
.map_err(|e| Self::backend(e.to_string()))?;
|
||||
let art = t
|
||||
.album
|
||||
.as_ref()
|
||||
.and_then(|a| a.image.best().cloned());
|
||||
let m = ItemMeta {
|
||||
title: t.title.clone(),
|
||||
subtitle: t
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default(),
|
||||
year: None,
|
||||
track_count: None,
|
||||
duration_secs: Some(t.duration as u64),
|
||||
quality: QualityHint {
|
||||
bit_depth: t.maximum_bit_depth,
|
||||
sample_rate_khz: t.maximum_sampling_rate,
|
||||
format: "FLAC",
|
||||
},
|
||||
art: ArtRef::None,
|
||||
badge: SourceBadge::Qobuz,
|
||||
kind_label: "track",
|
||||
};
|
||||
with_art(m, self, item, art)
|
||||
}
|
||||
ItemKind::Playlist => {
|
||||
let p = client
|
||||
.get_playlist(Self::numeric_id(item)?)
|
||||
.await
|
||||
.map_err(|e| Self::backend(e.to_string()))?;
|
||||
let art = p
|
||||
.image_rectangle
|
||||
.as_ref()
|
||||
.and_then(|v| v.first().cloned())
|
||||
.or_else(|| p.images300.as_ref().and_then(|v| v.first().cloned()))
|
||||
.or_else(|| p.images.as_ref().and_then(|v| v.first().cloned()));
|
||||
let m = ItemMeta {
|
||||
title: p.name.clone(),
|
||||
subtitle: p.owner.name.clone(),
|
||||
year: None,
|
||||
track_count: Some(p.tracks_count),
|
||||
duration_secs: Some(p.duration as u64),
|
||||
quality: QualityHint::default(),
|
||||
art: ArtRef::None,
|
||||
badge: SourceBadge::Qobuz,
|
||||
kind_label: "playlist",
|
||||
};
|
||||
with_art(m, self, item, art)
|
||||
}
|
||||
kind => {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::QOBUZ,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
};
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
|
||||
// CHEAP phase: memo only. A Qobuz id carries no cover on its own, and
|
||||
// this method must never hit the network.
|
||||
let Some(url) = self.art.read().ok().and_then(|m| m.get(item.id()).cloned()) else {
|
||||
return ArtRef::Unavailable("qobuz cover not resolved yet");
|
||||
};
|
||||
let sized = Self::small_url(&url, size.qobuz_px());
|
||||
ArtRef::Fetch {
|
||||
// The RAW (un-downscaled) url is the stable memo key across sizes;
|
||||
// `sized` is what gets fetched (artwork_qt.rs:231-238).
|
||||
cache_key: url,
|
||||
url: sized,
|
||||
}
|
||||
}
|
||||
|
||||
async fn playback(
|
||||
&self,
|
||||
item: &MediaRef,
|
||||
_track: &QueueTrack,
|
||||
) -> Result<PlaybackTicket, SourceError> {
|
||||
// The core owns the tier walk, the offline-cache lookup and the stream
|
||||
// setup (`core().play_track_resolved`); this crate does not link it.
|
||||
if item.kind() != ItemKind::Track {
|
||||
return Err(SourceError::Unsupported {
|
||||
by: SourceId::QOBUZ,
|
||||
kind: item.kind(),
|
||||
});
|
||||
}
|
||||
Ok(PlaybackTicket::Catalog {
|
||||
track_id: Self::numeric_id(item)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn bind_user(&self, uid: u64, _dir: &std::path::Path) {
|
||||
let previous = self.owner.lock().ok().and_then(|mut o| o.replace(uid));
|
||||
if previous.is_some() && previous != Some(uid) {
|
||||
// A rebind to a DIFFERENT uid clears the client even if nobody
|
||||
// remembered to tear down; the new one arrives separately, after
|
||||
// `set_session`.
|
||||
self.set_client(None);
|
||||
if let Ok(mut m) = self.art.write() {
|
||||
m.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn teardown(&self) {
|
||||
// MANDATORY override: the trait default is a no-op, and inheriting it
|
||||
// here would leave the PREVIOUS ACCOUNT's authenticated `QobuzClient`
|
||||
// alive in a `'static` registry across a logout, serving the next
|
||||
// account's requests.
|
||||
self.set_client(None);
|
||||
if let Ok(mut o) = self.owner.lock() {
|
||||
*o = None;
|
||||
}
|
||||
if let Ok(mut m) = self.art.write() {
|
||||
m.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Memoize the cover url on the source and stamp the resolved [`ArtRef`] into
|
||||
/// the metadata being built. One helper for the three `meta` arms.
|
||||
fn with_art(
|
||||
mut meta: ItemMeta,
|
||||
src: &QobuzSource,
|
||||
item: &MediaRef,
|
||||
url: Option<String>,
|
||||
) -> ItemMeta {
|
||||
src.memoize_art(item.id(), url);
|
||||
meta.art = Source::artwork(src, item, ArtSize::Card);
|
||||
meta
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn claimed(raw: &RawRef) -> Result<MediaRef, SourceError> {
|
||||
QobuzSource::new()
|
||||
.claim(raw)
|
||||
.expect("QobuzSource should claim this reference")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owns_opaque_album_ids_verbatim() {
|
||||
// Real ids from `mixtape_collection_items` / `album_play_history`
|
||||
// (survey §6.1): a barcode AND a base36-ish slug. Neither is parsed.
|
||||
for id in ["0060254702523", "e94no900otyrz", "e98mhimqarxfb"] {
|
||||
let m = claimed(&RawRef::new("qobuz", ItemKind::Album, id)).expect("qobuz album");
|
||||
assert_eq!(m.source(), SourceId::QOBUZ);
|
||||
assert_eq!(m.id(), id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owns_numeric_track_ids() {
|
||||
// Real `QueueTrack.id` (survey §6.1).
|
||||
let m = claimed(&RawRef::new("qobuz", ItemKind::Track, "139578884")).expect("qobuz track");
|
||||
assert_eq!(m.kind(), ItemKind::Track);
|
||||
assert_eq!(m.id(), "139578884");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_numeric_track_id_is_a_named_error() {
|
||||
// enqueue.rs:196-199 errors here today; now it errors at the claim.
|
||||
let err = claimed(&RawRef::new("qobuz", ItemKind::Track, "not-a-number")).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SourceError::BadIdShape {
|
||||
by: SourceId::QOBUZ,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_claims_by_elimination() {
|
||||
let q = QobuzSource::new();
|
||||
// survey IC-4/IC-6: a bare numeric with no source word is NOT Qobuz's.
|
||||
assert!(q
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "2954".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.is_none());
|
||||
// A local group key is not Qobuz's either.
|
||||
assert!(q
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "/home/u/Music/Artist/Album".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.is_none());
|
||||
// A `plex:` key with the umbrella `is_local = true` is not Qobuz's.
|
||||
assert!(q
|
||||
.claim(&RawRef {
|
||||
kind: Some(ItemKind::Album),
|
||||
id: "plex:5677211365378243606".into(),
|
||||
is_local: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlabelled_non_local_queue_row_is_qobuz() {
|
||||
// myqbz_play_qt.rs:246-248's rule: `is_local == false` is the ONLY
|
||||
// correct disambiguator for a bare numeric with no source word.
|
||||
let raw = RawRef {
|
||||
kind: Some(ItemKind::Track),
|
||||
id: "139578884".into(),
|
||||
is_local: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(claimed(&raw).expect("qobuz").id(), "139578884");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_urls_are_downscaled_per_size_class() {
|
||||
// myqbz_qt.rs:378-401 — real CDN url shape (survey §6.1).
|
||||
let url = "https://static.qobuz.com/images/covers/23/25/0060254702523_600.jpg";
|
||||
assert_eq!(
|
||||
QobuzSource::small_url(url, ArtSize::Row.qobuz_px()),
|
||||
"https://static.qobuz.com/images/covers/23/25/0060254702523_50.jpg"
|
||||
);
|
||||
// A url with no size token passes through unchanged.
|
||||
assert_eq!(QobuzSource::small_url("https://x/cover.png", 50), "https://x/cover.png");
|
||||
assert_eq!(QobuzSource::small_url("", 50), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artwork_is_unavailable_not_none_before_a_resolve() {
|
||||
let q = QobuzSource::new();
|
||||
let item = MediaRef::new(SourceId::QOBUZ, ItemKind::Album, "0060254702523");
|
||||
assert!(matches!(
|
||||
Source::artwork(&q, &item, ArtSize::Row),
|
||||
ArtRef::Unavailable(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user