Files
qbz/crates/qbz-source/src/sources/plex.rs
T

895 lines
35 KiB
Rust

//! 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 (`PlexSource::tracks` + `resolve_collection_tracks`) | raw `44440` | overwritten with `plex:<hash>` |
//!
//! Nobody wrote that table down: `plex_rating_key` (local_playback.rs:229-234,
//! copy-pasted to local_album_actions.rs:439-450) is one engineer's
//! 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); every frontend path uses the same namespace.
pub const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40;
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP` (artwork_qt.rs:82).
const MEMO_CAP: usize = 8192;
/// Map the Plex cache row into the queue shape owned by this source.
///
/// The raw numeric rating key is deliberately the queue id for MyQBZ. The
/// registry's claim path can then route playback back to the same Plex object,
/// while `resolve_collection_tracks` stamps the album boundary separately.
fn plex_queue_track(track: &PlexCachedTrack) -> QueueTrack {
let id = track.rating_key.parse().unwrap_or(track.id);
let sample_rate = (track.sample_rate > 0).then_some(track.sample_rate as f64 / 1000.0);
QueueTrack {
id,
title: track.title.clone(),
version: None,
artist: track.artist.clone(),
album: track.album.clone(),
album_version: None,
duration_secs: track.duration_secs,
artwork_url: track
.artwork_path
.clone()
.or_else(|| track.collection_artwork_path.clone()),
hires: track.bit_depth.is_some_and(|depth| depth > 16),
bit_depth: track.bit_depth,
sample_rate,
is_local: true,
album_id: Some(track.album_key.clone()),
artist_id: None,
streamable: true,
source: Some("plex".to_string()),
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
/// The Plex credentials, implemented by the frontend over its EXISTING store.
///
/// Plex credentials live in a per-user SQLite store `qbz-qt` already owns
/// (`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 built by
/// [`plex_queue_track`]);
/// 4. otherwise → [`SourceError::BadIdShape`]. Notably a NON-NUMERIC rating
/// key with no hint: today `playback_track_id` falls back to a hash for
/// those, so case 3 would silently produce a hash and 404. Here it is a
/// 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 => {
// The provider-owned replacement for the legacy mixtape and
// `local_plex::album_tracks` cache reads.
qbz_plex::plex_cache_get_album_tracks(item.id().to_string()).unwrap_or_default()
}
ItemKind::Track => {
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 preferred_art(item: &MediaRef, rows: &[PlexCachedTrack]) -> Option<String> {
rows.iter().find_map(|track| {
let item_art = track.artwork_path.clone().filter(|path| !path.is_empty());
let collection_art = track
.collection_artwork_path
.clone()
.filter(|path| !path.is_empty());
if item.kind() == ItemKind::Track {
item_art.or(collection_art)
} else {
collection_art.or(item_art)
}
})
}
fn memoize_art(&self, item: &MediaRef, rows: &[PlexCachedTrack]) {
let Some(path) = Self::preferred_art(item, rows) 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 {
if raw.source == Some(SourceId::PLEX) {
return true;
}
// AN EXPLICIT, DIFFERENT SOURCE WORD IS EVIDENCE AGAINST OWNERSHIP — see
// the long note in sources/subsonic.rs. A namespace bit must never
// outvote a caller who already said which source this is.
if raw.source.is_some() {
return false;
}
if raw.id.trim().starts_with("plex:") {
return true;
}
// A namespaced numeric is a TRACK rowid by construction. This arm is the
// LOOSEST of the three (a bare bit test, no payload bound), so an album
// barcode with bit 40 set used to land in `claim`'s Album branch and come
// back as `Err(BadIdShape)` — which registry.rs propagates IMMEDIATELY,
// ahead of any tiebreak. That failed the caller with a confusing *Plex*
// error on an id Plex never owned.
if raw.kind == Some(ItemKind::Album) {
return false;
}
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. `plex_queue_track` is chosen over
// `map_cached_to_local_track` + `local_queue_track`
// (local_plex.rs:276-302 → local_playback.rs:31-108) because it emits
// the RAW rating key as `QueueTrack.id`. That is survey requirement 2:
// the same Plex track now carries ONE queue id regardless of which view
// built the queue, instead of the two-way split in §6.2.
Ok(rows.iter().map(plex_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);
Self::preferred_art(item, &rows)
}
}
};
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,
}
}
fn artwork_token(&self, token: &str, size: ArtSize) -> ArtRef {
// The Plex arm of `artwork_qt::classify` (artwork_qt.rs:168-178), now
// owned by the source that knows what a `/library/...` path is. The
// caller no longer has to have heard of `is_thumb_path`.
let token = token.trim();
if token.is_empty() || !is_thumb_path(token) {
return ArtRef::None;
}
let Some((base, token_secret)) = 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_secret, token, 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: token.to_string(),
}
}
async fn playback(
&self,
item: &MediaRef,
track: &QueueTrack,
) -> Result<PlaybackTicket, SourceError> {
// Moved from `local_playback::play_plex_track`, minus the player calls
// (`play_data`, and the feeder handoff), which STAY 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();
// `plex_resolve_part_url`, NOT `plex_resolve_track_media`: the ticket
// resolves a LOCATION, it does not fetch a body. The frontend
// Range-streams it (~1s to first audio) and falls back to a plain GET
// of this same url when the feeder cannot start.
//
// Dropping the old `resolve_track_media` fallback is not a behaviour
// loss: the two functions run the SAME metadata request, the SAME
// parse and the SAME part-key extraction, and only then does
// `resolve_track_media` also GET the body. Every way the first can
// fail, the second fails identically — so the fallback was a second
// round trip that could only reproduce the first one's error.
//
// The part url is a DIRECT-PLAY url: the original on-disk bytes,
// bit-perfect. No transcode is requested here, and none may be.
match qbz_plex::plex_resolve_part_url(base, token, rating_key).await {
Ok(loc) => Ok(PlaybackTicket::Stream {
url: loc.part_url,
play_id: track.id,
// From the QUEUE row rather than the core's "current track":
// the caller may be priming a track that is not current yet
// (play-next, an album's first row), where the core would
// answer about the wrong one. 0 is acceptable — it only makes
// the feeder's estimate conservative.
duration_secs: track.duration_secs,
start_secs: 0,
log_tag: "PLEX",
request_headers: loc.request_headers,
}),
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;
fn cached_art_row(item_art: Option<&str>, collection_art: Option<&str>) -> PlexCachedTrack {
PlexCachedTrack {
id: 1,
rating_key: "1".into(),
title: "Track".into(),
artist: "Artist".into(),
album: "Album".into(),
duration_secs: 1,
format: "flac".into(),
bit_depth: Some(16),
sample_rate: 44_100,
artwork_path: item_art.map(str::to_string),
collection_artwork_path: collection_art.map(str::to_string),
source: "plex".into(),
album_key: "plex:album".into(),
track_number: Some(1),
disc_number: Some(1),
year: Some(2026),
parent_rating_key: Some("album".into()),
}
}
#[test]
fn queue_mapper_preserves_plex_identity_and_quality() {
let mut row = cached_art_row(Some("/disc/thumb"), Some("/collection/thumb"));
row.id = 99;
row.rating_key = "44440".into();
row.bit_depth = Some(24);
row.sample_rate = 192_000;
let queue = plex_queue_track(&row);
assert_eq!(queue.id, 44_440);
assert_eq!(queue.album_id.as_deref(), Some("plex:album"));
assert_eq!(queue.source.as_deref(), Some("plex"));
assert_eq!(queue.artwork_url.as_deref(), Some("/disc/thumb"));
assert_eq!(queue.bit_depth, Some(24));
assert_eq!(queue.sample_rate, Some(192.0));
assert!(queue.hires);
assert!(queue.is_local);
assert!(queue.streamable);
}
#[test]
fn track_art_prefers_the_disc_then_falls_back_to_the_collection() {
let item = MediaRef::new(SourceId::PLEX, ItemKind::Track, "1");
let row = cached_art_row(Some("/disc/thumb"), Some("/collection/thumb"));
assert_eq!(
PlexSource::preferred_art(&item, std::slice::from_ref(&row)).as_deref(),
Some("/disc/thumb")
);
let row = cached_art_row(None, Some("/collection/thumb"));
assert_eq!(
PlexSource::preferred_art(&item, &[row]).as_deref(),
Some("/collection/thumb")
);
}
#[test]
fn album_art_prefers_the_collection_then_falls_back_to_an_item() {
let item = MediaRef::new(SourceId::PLEX, ItemKind::Album, "plex:album");
let row = cached_art_row(Some("/disc/thumb"), Some("/collection/thumb"));
assert_eq!(
PlexSource::preferred_art(&item, std::slice::from_ref(&row)).as_deref(),
Some("/collection/thumb")
);
let row = cached_art_row(Some("/disc/thumb"), None);
assert_eq!(
PlexSource::preferred_art(&item, &[row]).as_deref(),
Some("/disc/thumb")
);
}
/// 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>`). 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).
#[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()
}));
}
// ── artwork_token: the Plex arm of the dead `classify` ──────────────────
/// With no credentials a Plex thumb is UNAVAILABLE, never `None`: the art
/// exists, the server does not answer yet, and the two must stay
/// distinguishable or the miss is logged as a dead download.
#[test]
fn a_thumb_path_without_credentials_is_unavailable_not_none() {
let p = PlexSource::new();
assert!(matches!(
p.artwork_token("/library/metadata/42/thumb/1", ArtSize::Card),
ArtRef::Unavailable(_)
));
}
/// Anything that is not one of the two declared thumb prefixes is not
/// Plex's to interpret — including an absolute path, which `classify`
/// would have called a LOCAL FILE purely because it starts with `/`.
#[test]
fn only_the_declared_thumb_prefixes_are_claimed() {
let p = PlexSource::new();
assert!(matches!(p.artwork_token("", ArtSize::Card), ArtRef::None));
assert!(matches!(
p.artwork_token("/home/u/Music/Album/cover.jpg", ArtSize::Card),
ArtRef::None
));
assert!(matches!(
p.artwork_token("https://static.qobuz.com/a.jpg", ArtSize::Card),
ArtRef::None
));
// Both prefixes the taxonomy declares.
assert!(is_thumb_path("/library/metadata/42/thumb/1"));
assert!(is_thumb_path("/photo/:/transcode"));
}
}