feat(source): JellyfinSource and SubsonicSource — §10's acceptance test, paid

Design 02 §10 predicted a fourth source would be "one impl plus one
registration line, zero edits in any view". Two of them landed, and the
prediction held: no arm in any view, none in the playback router, none
in the artwork pipeline, no entry in a url taxonomy. Every one of those
became structural during stages 3 and 4, which is what this commit is
the receipt for.

One honest correction to that claim, now written in sources/mod.rs: it
was true only AFTER stages 3 and 4. From stage 1 the seam existed but
playback and artwork never asked it anything, so a source added earlier
would have claimed correctly and then been silent and coverless.

`sources/remote.rs` holds what the pair shares — the cache handle and
the row mappers — so they cannot drift the way the three copies of
play_audible did. The two conversions that are easy to get backwards
(ms -> s, Hz -> kHz) live there once, with a test.

Identity, and it is the part with teeth. A queue row carries BOTH the
namespaced id the grid published and the server's own id in the hint,
and `claim` prefers the hint: rowids are reassigned by a cache rebuild,
the server's id is not, so a queue that outlives a re-sync still
resolves. Recognition is by source word or namespace bit ONLY — never by
the shape of an opaque id. A Jellyfin item id is a 32-char hex GUID and
a Subsonic one is base62, but neither is guaranteed, and inferring a
source from a string's alphabet is the class of guess the seam deletes.
Both sources are tested against every neighbouring floor: Plex's 1<<40,
each other's, the ephemeral store's 1<<48, and a plain local rowid.

The Subsonic brand spellings — navidrome, gonic, airsonic, astiga — fold
to ONE source. They speak one API and QBZ reads them through one impl,
so a row stamped with the server's brand must still resolve; returning
None would make it Unclaimed and it would refuse to play.

Artwork differs between the two, and only because the protocols do.
Jellyfin's cover url carries no credentials, so it is stable and its
cache_key can be the url itself. Subsonic's carries the salt and the
size, so its key is the opaque coverArt id instead — keying on the url
would re-download every cover whenever either moved, and would put a
credential in a cache key.

Both playback tickets are Stream with the bit-perfect contract spelled
out: `?static=true` for Jellyfin, `format=raw` for Subsonic, and tests
that fail if `audioCodec` or `maxBitRate` ever appear.

One test had to be rewritten rather than deleted: `from_word("jellyfin")
== None` was the example for "unknown words are not guessed into Qobuz".
The property still holds and is now spelled with "emby".

qbz-source 84/84, qbz-media-cache 11/11, qbz-qt still builds clean.

cc
This commit is contained in:
vicrodh committed 2026-08-20 07:58:03 -06:00
1 parent 0dfec607c2
commit b09f03b739
10 files changed
+1487 -19

No files matched your search

+22 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "qbz-source"
description = "The source-agnostic seam: one trait + one registry over Qobuz, local files and Plex"
description = "The source-agnostic seam: one trait + one registry over Qobuz, local files, Plex, Jellyfin and Subsonic"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -12,8 +12,18 @@ qbz-models = { path = "../qbz-models" }
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.
# Plex cache reads + plex_resolve_part_url — the Plex arm.
qbz-plex = { path = "../qbz-plex" }
# The two media-server protocol crates: URL builders and DTOs only. Their HTTP
# clients are never constructed here — this crate hands out URLs and lets the
# frontend's feeder follow them.
qbz-jellyfin = { path = "../qbz-jellyfin" }
qbz-subsonic = { path = "../qbz-subsonic" }
# The shared remote cache. Jellyfin and Subsonic answer every UI read from it.
qbz-media-cache = { path = "../qbz-media-cache" }
# Only to hold the cache's `Connection`. This crate never writes SQL of its own
# — every statement lives in `qbz-media-cache` or `qbz-library`.
rusqlite = { version = "0.31", features = ["bundled"] }
# `ItemResolver` — this crate IMPLEMENTS it (mixtape_adapter.rs). qbz-mixtape is
# NOT modified and NOT absorbed; the dependency is one-directional so the `qbz`
# (Slint) crate keeps compiling untouched.
@@ -25,4 +35,13 @@ 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.
# tokio (this crate never spawns), reqwest, qbz-i18n, qbz-cache.
#
# `rusqlite` IS here now, and only to own a `Connection` for the shared remote
# cache — the SQL itself lives in `qbz-media-cache`. That is the same shape
# `qbz-library` already has with this crate: a handle here, the statements
# there.
[dev-dependencies]
# `#[tokio::test]` only — the crate itself never spawns (see the note above).
tokio = { workspace = true }
+33 -2
View File
@@ -28,6 +28,12 @@ impl SourceId {
pub const QOBUZ: SourceId = SourceId("qobuz");
/// A Plex Media Server (through the local Plex cache).
pub const PLEX: SourceId = SourceId("plex");
/// A Jellyfin server (through `qbz-media-cache`).
pub const JELLYFIN: SourceId = SourceId("jellyfin");
/// A Subsonic-compatible server — Navidrome, Gonic, Airsonic, Astiga,
/// Ampache. ONE source, because they speak one API; the server's own
/// flavour is a display detail, not an identity.
pub const SUBSONIC: SourceId = SourceId("subsonic");
/// Files on this machine: the indexed library, Qobuz downloads/purchases
/// and the session-scoped ephemeral folder.
pub const LOCAL: SourceId = SourceId("local");
@@ -50,6 +56,9 @@ impl SourceId {
/// | `"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` |
/// | `"jellyfin"` | `QueueTrack.source`, `remote_cache_tracks.source` | `JELLYFIN` |
/// | `"subsonic"` | `QueueTrack.source`, `remote_cache_tracks.source` | `SUBSONIC` |
/// | `"navidrome"`, `"gonic"`, `"airsonic"` | a user's or a producer's spelling of the server they run | `SUBSONIC` |
///
/// Unknown words return `None`. They are never guessed into `QOBUZ`, which
/// is `myqbz_add_qt::source_from_str`'s bug (survey IC-6).
@@ -57,6 +66,13 @@ impl SourceId {
match w.trim().to_ascii_lowercase().as_str() {
"qobuz" => Some(SourceId::QOBUZ),
"plex" => Some(SourceId::PLEX),
"jellyfin" => Some(SourceId::JELLYFIN),
// The Subsonic FLAVOURS fold into one source. They speak one API
// and QBZ reads them through one impl, so a row stamped with the
// server's brand must still resolve — a `"navidrome"` word that
// returned None would be `Unclaimed` and the row would refuse to
// play, which is a worse answer than the honest fold.
"subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => Some(SourceId::SUBSONIC),
"local" | "user" | "qobuz_download" | "qobuz_purchase" | "offline" | "ephemeral" => {
Some(SourceId::LOCAL)
}
@@ -301,9 +317,24 @@ mod tests {
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("jellyfin"), Some(SourceId::JELLYFIN));
// Every Subsonic BRAND folds to one source: they speak one API and QBZ
// reads them through one impl, so a row stamped with the server's name
// must still resolve. A `"navidrome"` that returned None would be
// `Unclaimed`, and the row would refuse to play.
for brand in ["subsonic", "navidrome", "gonic", "airsonic", "astiga"] {
assert_eq!(
SourceId::from_word(brand),
Some(SourceId::SUBSONIC),
"{brand} did not fold"
);
}
// Unknown / absent is NOT guessed into Qobuz (survey IC-6). This
// assertion used to be spelled with "jellyfin", which is why it needed
// rewriting rather than deleting when Jellyfin became real — the
// PROPERTY is what matters, not the example.
assert_eq!(SourceId::from_word(""), None);
assert_eq!(SourceId::from_word("jellyfin"), None);
assert_eq!(SourceId::from_word("emby"), None);
assert_eq!(SourceId::from_word("???"), None);
}
+2 -2
View File
@@ -105,6 +105,6 @@ pub use playback::PlaybackTicket;
pub use registry::{init_registry, registry, SourceRegistry};
pub use source::Source;
pub use sources::{
local_queue_track, ClientFuture, ClientLens, EphemeralTracks, LocalSource, PlexCreds,
PlexSource, QobuzSource,
local_queue_track, CacheHandle, ClientFuture, ClientLens, EphemeralTracks, JellyfinCreds,
JellyfinSource, LocalSource, PlexCreds, PlexSource, QobuzSource, SubsonicCreds, SubsonicSource,
};
+7
View File
@@ -95,6 +95,8 @@ pub enum SourceBadge {
Local,
Offline,
Plex,
Jellyfin,
Subsonic,
Qobuz,
}
@@ -105,6 +107,9 @@ impl SourceBadge {
pub fn from_word(raw: Option<&str>) -> SourceBadge {
match raw.map(str::trim) {
Some("plex") => SourceBadge::Plex,
Some("jellyfin") => SourceBadge::Jellyfin,
Some("subsonic") | Some("navidrome") | Some("gonic") | Some("airsonic")
| Some("astiga") => SourceBadge::Subsonic,
Some("qobuz_download") | Some("qobuz_purchase") | Some("offline") => {
SourceBadge::Offline
}
@@ -119,6 +124,8 @@ impl SourceBadge {
SourceBadge::Local => "local",
SourceBadge::Offline => "offline",
SourceBadge::Plex => "plex",
SourceBadge::Jellyfin => "jellyfin",
SourceBadge::Subsonic => "subsonic",
SourceBadge::Qobuz => "qobuz",
}
}
+32 -8
View File
@@ -11,18 +11,22 @@ use crate::id::{MediaRef, RawRef, SourceId};
use crate::meta::ItemMeta;
use crate::playback::PlaybackTicket;
use crate::source::Source;
use crate::sources::{ClientLens, LocalSource, PlexSource, QobuzSource};
use crate::sources::{
ClientLens, JellyfinSource, LocalSource, PlexSource, QobuzSource, SubsonicSource,
};
/// 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
// Typed handles for the five 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.
// client, a Plex creds impl, a Subsonic `Credentials`, a user directory +
// the ephemeral store), and `&Arc<dyn Source>` cannot express that.
// Concrete accessors beat a downcast.
qobuz: Arc<QobuzSource>,
plex: Arc<PlexSource>,
jellyfin: Arc<JellyfinSource>,
subsonic: Arc<SubsonicSource>,
local: Arc<LocalSource>,
}
@@ -50,15 +54,23 @@ impl SourceRegistry {
fn build(qobuz: QobuzSource) -> Self {
let qobuz = Arc::new(qobuz);
let plex = Arc::new(PlexSource::new());
let jellyfin = Arc::new(JellyfinSource::new());
let subsonic = Arc::new(SubsonicSource::new());
let local = Arc::new(LocalSource::new());
Self {
sources: vec![
qobuz.clone() as Arc<dyn Source>,
plex.clone() as Arc<dyn Source>,
// The acceptance test of design §10, and it really was one line
// each. Order is not load-bearing (see `with_defaults`).
jellyfin.clone() as Arc<dyn Source>,
subsonic.clone() as Arc<dyn Source>,
local.clone() as Arc<dyn Source>,
],
qobuz,
plex,
jellyfin,
subsonic,
local,
}
}
@@ -83,6 +95,17 @@ impl SourceRegistry {
&self.plex
}
/// The Jellyfin source, for `set_creds` and for the sync to write through
/// its cache handle.
pub fn jellyfin(&self) -> &JellyfinSource {
&self.jellyfin
}
/// The Subsonic source, same two reasons.
pub fn subsonic(&self) -> &SubsonicSource {
&self.subsonic
}
/// The local source.
///
/// PUBLIC because it carries the `library.db` accessors
@@ -106,9 +129,10 @@ impl SourceRegistry {
/// 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.
/// candidates. It cannot happen with today's five — their predicates are
/// disjoint, and the id-namespace matrix in `qbz-media-cache` is what
/// keeps the three cached sources apart — 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),
+573
View File
@@ -0,0 +1,573 @@
//! Jellyfin — the acceptance test of design 02 §10, cashed in.
//!
//! §10 said a fourth source should be "one impl plus one registration line".
//! This is that impl. What it does NOT contain is the interesting part: no arm
//! in any view, no arm in the playback router, no arm in the artwork pipeline,
//! no entry in a url taxonomy. Those all became structural during stages 3 and
//! 4, and this file is what proves it.
//!
//! It reads the shared cache (`qbz-media-cache`), never the network — except
//! for the two places where a URL is HANDED OUT rather than followed:
//! [`Source::playback`] and the fetch half of artwork.
use std::path::Path;
use std::sync::{Arc, RwLock};
use qbz_media_cache::RemoteSource;
use qbz_models::QueueTrack;
use crate::art::{ArtRef, ArtSize};
use crate::error::SourceError;
use crate::id::{ItemKind, MediaRef, RawRef, SourceId};
use crate::meta::{ItemMeta, SourceBadge};
use crate::playback::PlaybackTicket;
use crate::source::Source;
use crate::sources::remote::{cached_to_queue_track, meta_of_rows, not_found, CacheHandle};
/// The Jellyfin connection, implemented by the frontend over its own settings
/// store.
///
/// Injected for the same reason [`crate::PlexCreds`] is: opening a second copy
/// of the settings database here would be a second authority, and it would drag
/// `qbz-app` into this crate's graph, which design §8 forbids.
pub trait JellyfinCreds: Send + Sync + 'static {
/// The master toggle.
fn is_enabled(&self) -> bool;
/// `(base_url, access_token)`, both non-empty, or `None`.
fn server(&self) -> Option<(String, String)>;
}
/// The Jellyfin source.
pub struct JellyfinSource {
creds: RwLock<Option<Arc<dyn JellyfinCreds>>>,
cache: CacheHandle,
}
impl JellyfinSource {
pub fn new() -> Self {
Self {
creds: RwLock::new(None),
cache: CacheHandle::new(RemoteSource::Jellyfin, SourceId::JELLYFIN),
}
}
/// Publish the frontend's credentials handle. `None` clears it.
pub fn set_creds(&self, c: Option<Arc<dyn JellyfinCreds>>) {
if let Ok(mut slot) = self.creds.write() {
*slot = c;
}
}
/// `(base_url, token)` when Jellyfin is usable right now.
///
/// Clone-then-drop: the guard is never held across an `.await`. It is a
/// `std::sync::RwLock`, whose guard is `!Send`, so holding it across one
/// 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())
}
/// The cache handle, for the frontend's sync to write through.
pub fn cache(&self) -> &CacheHandle {
&self.cache
}
/// The rows behind a claimed reference.
fn rows(&self, item: &MediaRef) -> Vec<qbz_media_cache::CachedTrack> {
self.cache
.with(|c| match item.kind() {
ItemKind::Album => {
qbz_media_cache::album_tracks(c, RemoteSource::Jellyfin, item.id())
.unwrap_or_default()
}
ItemKind::Track => {
// A normalised track ref carries the SERVER's item id — see
// `claim`, which is where the two identities are reconciled.
qbz_media_cache::track_by_item_id(c, RemoteSource::Jellyfin, item.id())
.ok()
.flatten()
.into_iter()
.collect()
}
_ => Vec::new(),
})
.unwrap_or_default()
}
}
impl Default for JellyfinSource {
fn default() -> Self {
Self::new()
}
}
/// POSITIVE ownership: the source word, or the namespace bit on a numeric id.
///
/// Never by elimination, and never by the SHAPE of an opaque id — a Jellyfin
/// item id is a 32-character hex GUID and a Subsonic one is base62, but neither
/// is guaranteed, and guessing a source from a string's alphabet is exactly the
/// class of inference the seam exists to delete.
pub(crate) fn recognises(raw: &RawRef) -> bool {
raw.source == Some(SourceId::JELLYFIN)
|| raw
.numeric()
.map(|n| RemoteSource::of_id(n as i64) == Some(RemoteSource::Jellyfin))
.unwrap_or(false)
}
#[async_trait::async_trait]
impl Source for JellyfinSource {
fn id(&self) -> SourceId {
SourceId::JELLYFIN
}
fn claim(&self, raw: &RawRef) -> Option<Result<MediaRef, SourceError>> {
if !recognises(raw) {
return None;
}
let id = raw.id.trim();
let kind = raw.kind.unwrap_or(ItemKind::Track);
Some(match kind {
// An album id is the server's own, opaque and non-numeric.
ItemKind::Album => {
if id.is_empty() {
Err(SourceError::BadIdShape {
by: SourceId::JELLYFIN,
id: id.to_string(),
why: "a jellyfin album id is the server's item id, never empty",
})
} else {
Ok(MediaRef::new(SourceId::JELLYFIN, ItemKind::Album, id))
}
}
// A TRACK arrives in one of two shapes and both must normalise to
// the SERVER's item id, because that is the only thing the server
// and the cache both understand:
//
// 1. the namespaced numeric id the Local Library grid published,
// plus the server id in the hint (`cached_to_queue_track`);
// 2. the server id outright, from a producer that has it.
//
// Preferring the HINT is what makes a queue row survive a cache
// rebuild: rowids are reassigned, the server's id is not.
ItemKind::Track => {
if let Some(hint) = raw.hint_str().filter(|h| !h.is_empty()) {
Ok(MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, hint))
} else if let Some(n) = raw.numeric() {
match self
.cache
.with(|c| qbz_media_cache::track_by_id(c, n as i64))
.and_then(|r| r.ok())
.flatten()
{
Some(t) => {
Ok(MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, &t.item_id))
}
// The row is gone from the cache: a named error at the
// moment of the mistake, not a 404 from the server two
// layers down.
None => Err(SourceError::NotFound {
by: SourceId::JELLYFIN,
kind: ItemKind::Track,
id: raw.id.clone(),
}),
}
} else if !id.is_empty() {
Ok(MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, id))
} else {
Err(SourceError::BadIdShape {
by: SourceId::JELLYFIN,
id: id.to_string(),
why: "a jellyfin track ref needs the server item id or a namespaced row id",
})
}
}
other => Err(SourceError::Unsupported {
by: SourceId::JELLYFIN,
kind: other,
}),
})
}
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
// Cache reads only — no `.await` in this body.
let rows = self.rows(item);
if rows.is_empty() {
return Err(not_found(SourceId::JELLYFIN, item));
}
Ok(rows
.iter()
.map(|t| cached_to_queue_track(t, SourceId::JELLYFIN))
.collect())
}
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
let rows = self.rows(item);
if rows.is_empty() {
return Err(not_found(SourceId::JELLYFIN, item));
}
let art = self.artwork(item, ArtSize::Card);
Ok(meta_of_rows(&rows, item, SourceBadge::Jellyfin, art))
}
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
let Some(token) = self
.rows(item)
.iter()
.find_map(|t| t.artwork_token.clone().filter(|s| !s.is_empty()))
else {
return ArtRef::None;
};
// The album id is what the image hangs off; the token only versions it.
let album_id = self
.rows(item)
.first()
.map(|t| t.album_id.clone())
.unwrap_or_default();
self.image_ref(&album_id, Some(&token), size)
}
fn artwork_token(&self, token: &str, size: ArtSize) -> ArtRef {
// The token here is a bare image TAG with no item to hang it off, which
// Jellyfin cannot address. The rows path (`artwork`) is the one that
// has both; this arm exists so a producer that only kept the tag gets
// an honest `None` rather than a broken URL.
//
// A producer that stamped `"<albumId>/<tag>"` — which is what the Local
// Library row mapper will do — is addressable, so that shape resolves.
let token = token.trim();
if token.is_empty() {
return ArtRef::None;
}
match token.split_once('/') {
Some((album_id, tag)) => self.image_ref(album_id, Some(tag), size),
None => ArtRef::None,
}
}
async fn playback(
&self,
item: &MediaRef,
track: &QueueTrack,
) -> Result<PlaybackTicket, SourceError> {
if item.kind() != ItemKind::Track {
return Err(SourceError::Unsupported {
by: SourceId::JELLYFIN,
kind: item.kind(),
});
}
let (base, token) = self.server().ok_or(SourceError::NotConfigured {
by: SourceId::JELLYFIN,
why: "no Jellyfin credentials configured",
})?;
// `item.id()` IS the server's item id by construction (see `claim`), so
// nothing is re-derived here and no caller can smuggle a row id in.
//
// `stream_url` asks for `?static=true`: the ORIGINAL bytes, verified
// md5-identical to the file on the server's disk, with a
// `Content-Length` and `Accept-Ranges` so the feeder can Range-stream
// it. No transcode is requested and none may be.
Ok(PlaybackTicket::Stream {
url: qbz_jellyfin::stream_url(&base, &token, item.id()),
play_id: track.id,
duration_secs: track.duration_secs,
start_secs: 0,
log_tag: "JELLYFIN",
})
}
fn bind_user(&self, _uid: u64, dir: &Path) {
self.cache.bind(dir);
}
fn teardown(&self) {
self.set_creds(None);
self.cache.teardown();
}
}
impl JellyfinSource {
/// The [`ArtRef`] for an album image.
///
/// **No credentials in the url** — measured: `/Items/{id}/Images/Primary`
/// answers 200 unauthenticated. That is why `cache_key` can be the url
/// itself: unlike a Plex thumb it does not rotate, so keying on it can
/// never miss a cover already on disk.
fn image_ref(&self, album_id: &str, tag: Option<&str>, size: ArtSize) -> ArtRef {
if album_id.is_empty() {
return ArtRef::None;
}
let Some((base, _)) = self.server() else {
// The art EXISTS; the server is not connected. Distinct from `None`
// so the miss is logged for what it is rather than looking like a
// dead download.
return ArtRef::Unavailable("jellyfin is not connected");
};
let px = match size {
ArtSize::Full => qbz_jellyfin::IMAGE_PX_LARGE,
_ => qbz_jellyfin::IMAGE_PX,
};
let url = qbz_jellyfin::image_url(&base, album_id, tag, px);
ArtRef::Fetch {
cache_key: url.clone(),
url,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Creds;
impl JellyfinCreds for Creds {
fn is_enabled(&self) -> bool {
true
}
fn server(&self) -> Option<(String, String)> {
Some(("http://jf:8096".into(), "tok".into()))
}
}
fn connected() -> JellyfinSource {
let s = JellyfinSource::new();
s.set_creds(Some(Arc::new(Creds)));
s
}
/// A minimal `QueueTrack`. `qbz_models::QueueTrack` has no `Default` — its
/// `streamable` field defaults to TRUE through serde, and a derived
/// `Default` would silently make it false, so the model refuses to provide
/// one. Building it out here keeps that guarantee where it belongs.
fn qt(id: u64, duration_secs: u64) -> QueueTrack {
QueueTrack {
id,
title: String::new(),
version: None,
artist: String::new(),
album: String::new(),
album_version: None,
duration_secs,
artwork_url: None,
hires: false,
bit_depth: None,
sample_rate: None,
is_local: true,
album_id: None,
artist_id: None,
streamable: true,
source: None,
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
fn track_ref(id: &str, hint: Option<&str>) -> RawRef {
RawRef {
source: Some(SourceId::JELLYFIN),
badge: SourceBadge::Jellyfin,
kind: Some(ItemKind::Track),
id: id.into(),
is_local: Some(true),
hint: hint.map(String::from),
}
}
// ── claim ──────────────────────────────────────────────────────────────
/// The HINT wins over the numeric id, and that is the point: rowids are
/// reassigned by a cache rebuild, the server's item id is not. A queue row
/// stamped before the rebuild still resolves.
#[test]
fn a_track_normalises_to_the_server_id_from_the_hint() {
let s = connected();
let m = s
.claim(&track_ref("2199023255559", Some("srv-abc")))
.unwrap()
.unwrap();
assert_eq!(m.source(), SourceId::JELLYFIN);
assert_eq!(m.id(), "srv-abc", "the row id was used instead of the hint");
}
/// Recognition by the namespace bit alone, with no source word — the shape
/// a mixtape item arrives in.
#[test]
fn the_namespace_bit_is_enough_to_recognise() {
let namespaced = RemoteSource::Jellyfin.namespace(7).to_string();
assert!(recognises(&RawRef {
kind: Some(ItemKind::Track),
id: namespaced,
..Default::default()
}));
}
/// And it must claim NOTHING that belongs to a neighbour. Plex's floor is
/// bit 40, Subsonic's is 42, the ephemeral store's is 48 — a predicate that
/// answered "mine" for any of them would route the row to the wrong server.
#[test]
fn it_never_claims_another_sources_id() {
for foreign in [
(1i64 << 40) | 44_440, // Plex
RemoteSource::Subsonic.namespace(7),
(1i64 << 48) + 10, // ephemeral
2954, // a local_tracks rowid
] {
assert!(
!recognises(&RawRef {
kind: Some(ItemKind::Track),
id: foreign.to_string(),
..Default::default()
}),
"claimed {foreign}"
);
}
// A Qobuz album id, and a local group key.
assert!(!recognises(&RawRef::new("qobuz", ItemKind::Album, "0060254702523")));
assert!(!recognises(&RawRef {
kind: Some(ItemKind::Album),
id: "HIT ME HARD AND SOFT|Billie Eilish".into(),
..Default::default()
}));
}
#[test]
fn an_empty_album_id_is_a_named_error_not_a_lookup() {
let s = connected();
let err = s
.claim(&RawRef {
source: Some(SourceId::JELLYFIN),
kind: Some(ItemKind::Album),
id: " ".into(),
..Default::default()
})
.unwrap()
.unwrap_err();
assert!(matches!(err, SourceError::BadIdShape { by: SourceId::JELLYFIN, .. }));
}
#[test]
fn an_artist_reference_is_unsupported_rather_than_guessed() {
let s = connected();
let err = s
.claim(&RawRef {
source: Some(SourceId::JELLYFIN),
kind: Some(ItemKind::Artist),
id: "abc".into(),
..Default::default()
})
.unwrap()
.unwrap_err();
assert!(matches!(err, SourceError::Unsupported { by: SourceId::JELLYFIN, .. }));
}
// ── artwork ────────────────────────────────────────────────────────────
/// The cover url carries NO token, which is why its cache key can be the
/// url itself.
#[test]
fn a_cover_url_is_tokenless_and_self_keying() {
let s = connected();
match s.artwork_token("alb-1/deadbeef", ArtSize::Card) {
ArtRef::Fetch { url, cache_key } => {
assert_eq!(url, cache_key, "a stable url should key on itself");
assert!(!url.contains("tok"), "the access token leaked into a cover url");
assert!(url.contains("/Items/alb-1/Images/Primary"));
assert!(url.contains("tag=deadbeef"));
assert!(url.contains("maxWidth=256"));
}
other => panic!("expected Fetch, got {other:?}"),
}
// The Full tier asks for the larger transcode.
match s.artwork_token("alb-1/deadbeef", ArtSize::Full) {
ArtRef::Fetch { url, .. } => assert!(url.contains("maxWidth=1024")),
other => panic!("expected Fetch, got {other:?}"),
}
}
/// Disconnected is UNAVAILABLE, never None: the art exists, the server does
/// not answer yet, and collapsing the two loses the retry.
#[test]
fn a_cover_without_a_server_is_unavailable_not_none() {
let s = JellyfinSource::new();
assert!(matches!(
s.artwork_token("alb-1/tag", ArtSize::Card),
ArtRef::Unavailable(_)
));
// ...but a token with nothing to hang it off is genuinely None.
assert!(matches!(
connected().artwork_token("bare-tag", ArtSize::Card),
ArtRef::None
));
assert!(matches!(connected().artwork_token("", ArtSize::Card), ArtRef::None));
}
// ── playback ───────────────────────────────────────────────────────────
#[tokio::test]
async fn playback_asks_for_the_original_bytes() {
let s = connected();
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, "srv-abc");
let qt = qt(42, 188);
match s.playback(&item, &qt).await.unwrap() {
PlaybackTicket::Stream {
url,
play_id,
duration_secs,
log_tag,
..
} => {
assert!(url.contains("/Audio/srv-abc/stream?static=true"));
assert!(!url.contains("audioCodec"), "a codec parameter IS a transcode");
assert_eq!(play_id, 42);
assert_eq!(duration_secs, 188);
assert_eq!(log_tag, "JELLYFIN");
}
other => panic!("expected Stream, got {other:?}"),
}
}
#[tokio::test]
async fn playback_without_credentials_is_not_configured() {
let s = JellyfinSource::new();
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, "x");
let err = s.playback(&item, &qt(0, 0)).await.unwrap_err();
assert!(matches!(err, SourceError::NotConfigured { by: SourceId::JELLYFIN, .. }));
}
/// An ALBUM cannot be played directly — the caller expands it to tracks
/// first. Saying so beats streaming something arbitrary.
#[tokio::test]
async fn an_album_is_not_playable_on_its_own() {
let s = connected();
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Album, "alb");
let err = s.playback(&item, &qt(0, 0)).await.unwrap_err();
assert!(matches!(err, SourceError::Unsupported { by: SourceId::JELLYFIN, .. }));
}
/// A disabled toggle is the same as no credentials — the source must not
/// serve a stale connection after the user switches it off.
#[test]
fn a_disabled_source_reports_no_server() {
struct Off;
impl JellyfinCreds for Off {
fn is_enabled(&self) -> bool {
false
}
fn server(&self) -> Option<(String, String)> {
Some(("http://jf:8096".into(), "tok".into()))
}
}
let s = JellyfinSource::new();
s.set_creds(Some(Arc::new(Off)));
assert!(s.server().is_none());
}
}
+22 -4
View File
@@ -1,13 +1,31 @@
//! 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.
//! The five implementations that exist.
//!
//! The header used to say "three, and the one obvious slot a fourth 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."
//!
//! That prediction is now cashed in twice over, and it held — with one honest
//! correction. It was true only AFTER stages 3 and 4 landed: the seam existed
//! from stage 1, but playback and artwork never asked it anything until then,
//! so a source added earlier would have been claimed correctly and stayed
//! silent and coverless. See `qbz-qt/src/source_wiring.rs` for what that cost.
//!
//! `remote` holds what Jellyfin and Subsonic share — the cache handle and the
//! row mappers — so the pair cannot drift the way the three copies of
//! `play_audible` did.
pub mod jellyfin;
pub mod local;
pub mod plex;
mod pool;
pub mod qobuz;
pub mod remote;
pub mod subsonic;
pub use jellyfin::{JellyfinCreds, JellyfinSource};
pub use local::{local_queue_track, EphemeralTracks, LocalSource};
pub use plex::{is_thumb_path, PlexCreds, PlexSource, PLEX_TRACK_ID_FLOOR};
pub use qobuz::{ClientFuture, ClientLens, QobuzSource};
pub use remote::CacheHandle;
pub use subsonic::{SubsonicCreds, SubsonicSource};
+344
View File
@@ -0,0 +1,344 @@
//! What Jellyfin and Subsonic have in common.
//!
//! Both are REMOTE CACHED sources: their library is swept into
//! `qbz-media-cache` and every read the UI makes is answered from that mirror,
//! not from the server. Only [`Source::playback`] and the artwork FETCH touch
//! the network, and both do it by handing out a URL rather than a body.
//!
//! Plex is the same shape and is not folded in here yet — it reads
//! `plex_cache.db` through `qbz-plex`'s own global and carries a five-id
//! identity problem this pair does not have. It joins once the shared cache has
//! proved itself with these two (see `qbz-media-cache`'s header).
//!
//! # Why the connection is a `Mutex<Option<Connection>>`
//!
//! `rusqlite::Connection` is `!Sync`, so it cannot simply live behind a shared
//! reference. `LocalSource` has a real pool (`DbPool`) because `library.db` is
//! read from 48 call sites at scroll speed; this cache is read by the grid in
//! batches and by playback once per track, so one guarded connection is enough
//! and a pool would be machinery without a measurement behind it. If that ever
//! stops being true, the fix is to reuse `DbPool`, not to hand-roll a second
//! one.
//!
//! The guard is `std::sync::Mutex` and is NEVER held across an `.await` — the
//! guard is `!Send`, so doing it inside an `#[async_trait]` method is a compile
//! error rather than a review rule. Same discipline as `PlexSource::server`.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use qbz_media_cache::{CachedTrack, RemoteSource};
use qbz_models::QueueTrack;
use crate::art::ArtRef;
use crate::error::SourceError;
use crate::id::{ItemKind, MediaRef, SourceId};
use crate::meta::{ItemMeta, QualityHint, SourceBadge};
/// The per-user cache handle a remote source owns.
/// PUBLIC because the frontend's SYNC writes through it: `qbz-source` owns
/// the handle (so it is bound and torn down with the rest of the registry),
/// while the sweep that fills it lives in the frontend, where the progress UI
/// and the tokio runtime are.
pub struct CacheHandle {
which: RemoteSource,
id: SourceId,
path: Mutex<Option<PathBuf>>,
conn: Mutex<Option<rusqlite::Connection>>,
}
impl CacheHandle {
pub(crate) fn new(which: RemoteSource, id: SourceId) -> Self {
Self {
which,
id,
path: Mutex::new(None),
conn: Mutex::new(None),
}
}
/// The cache file for the active user. ONE file for every remote source —
/// the table's `source` column is what separates them, which is the whole
/// point of the shared schema.
pub fn bind(&self, dir: &Path) {
let p = dir.join("remote_cache.db");
if let Ok(mut slot) = self.path.lock() {
*slot = Some(p);
}
// Drop the previous user's handle rather than reopening eagerly: a user
// who never opens Local Library should not pay for a database open, and
// the first read will do it.
if let Ok(mut c) = self.conn.lock() {
*c = None;
}
}
pub fn teardown(&self) {
if let Ok(mut slot) = self.path.lock() {
*slot = None;
}
if let Ok(mut c) = self.conn.lock() {
*c = None;
}
}
/// Run `f` against the cache, opening it on first use.
///
/// `None` means "no user bound, or the cache could not be opened" — the
/// same shape `local_state::with_db` has, and the callers treat it the same
/// way: an empty result, never a panic.
pub fn with<R>(&self, f: impl FnOnce(&rusqlite::Connection) -> R) -> Option<R> {
let mut guard = self.conn.lock().ok()?;
if guard.is_none() {
let path = self.path.lock().ok()?.clone()?;
match qbz_media_cache::open(&path) {
Ok(c) => *guard = Some(c),
Err(e) => {
log::warn!("[qbz-source] {} cache open failed: {e}", self.id);
return None;
}
}
}
guard.as_ref().map(f)
}
/// Run `f` against the cache with WRITE access. Same handle — SQLite
/// serialises writers itself and this one is behind a mutex anyway.
pub fn with_mut<R>(
&self,
f: impl FnOnce(&mut rusqlite::Connection) -> R,
) -> Option<R> {
let mut guard = self.conn.lock().ok()?;
if guard.is_none() {
let path = self.path.lock().ok()?.clone()?;
match qbz_media_cache::open(&path) {
Ok(c) => *guard = Some(c),
Err(e) => {
log::warn!("[qbz-source] {} cache open failed: {e}", self.id);
return None;
}
}
}
guard.as_mut().map(f)
}
pub fn which(&self) -> RemoteSource {
self.which
}
}
/// The one place a cached row becomes a `QueueTrack`.
///
/// Shared rather than written per source because every field decision below is
/// a decision about QBZ, not about the vendor — and two copies would drift the
/// way the three `play_audible`s did.
pub(crate) fn cached_to_queue_track(t: &CachedTrack, source: SourceId) -> QueueTrack {
QueueTrack {
// The NAMESPACED id. It is the one the Local Library grid publishes and
// the one `claim` recognises, so the queue must carry the same number
// or "play from the queue" and "play from the grid" disagree — which is
// bug 2, in a new source.
id: t.id as u64,
title: t.title.clone(),
version: None,
artist: t.artist.clone(),
album: t.album.clone(),
album_version: None,
duration_secs: t.duration_ms / 1000,
// The RAW artwork token, not a URL. A URL embeds credentials and a
// size; the token is stable, and `artwork_token` is what turns it into
// something fetchable at the moment it is needed. Storing the URL here
// is how a queue row ends up with a stale token in it.
artwork_url: t.artwork_token.clone(),
hires: t.bit_depth.map(|d| d > 16).unwrap_or(false),
bit_depth: t.bit_depth,
// kHz — the `QueueTrack` convention, NOT Hz.
sample_rate: t.sample_rate_hz.map(|hz| hz as f64 / 1000.0),
// TRUE, and it is not a stub: `is_local` gates the frontend's
// "this row does not go through the Qobuz tier walk" behaviour, which
// is exactly right for a row served by someone else's server.
is_local: true,
album_id: Some(t.album_id.clone()),
artist_id: None,
// A remote server's rights are not Qobuz's. `qbz-core` never asks about
// this row, and marking it unstreamable would drop it from the queue.
streamable: true,
source: Some(source.as_str().to_string()),
parental_warning: false,
// The SERVER's own id. `claim` prefers it over the numeric id, so a
// queue row survives a cache rebuild that renumbers rowids — the one
// way the namespaced id can legitimately change.
source_item_id_hint: Some(t.item_id.clone()),
context_kind: None,
context_id: None,
}
}
/// Row-level display fields for one album or track.
pub(crate) fn meta_of_rows(
rows: &[CachedTrack],
item: &MediaRef,
badge: SourceBadge,
art: ArtRef,
) -> ItemMeta {
let first = rows.first();
ItemMeta {
title: match (item.kind(), first) {
(ItemKind::Track, Some(f)) => f.title.clone(),
(_, Some(f)) => f.album.clone(),
(_, None) => String::new(),
},
subtitle: first
.map(|f| {
if f.album_artist.is_empty() {
f.artist.clone()
} else {
f.album_artist.clone()
}
})
.unwrap_or_default(),
year: first.and_then(|f| f.year),
track_count: Some(rows.len() as u32),
duration_secs: Some(rows.iter().map(|t| t.duration_ms / 1000).sum()),
quality: first
.map(|f| {
QualityHint::from_hz(
f.bit_depth,
f.sample_rate_hz.unwrap_or(0) as f64,
// The tier only needs to know whether it is lossy; the
// container name itself is display data.
if f.container.eq_ignore_ascii_case("mp3")
|| f.codec.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("mp3"))
{
"MP3"
} else {
"FLAC"
},
)
})
.unwrap_or_default(),
art,
badge,
kind_label: item.kind().label(),
}
}
/// `NotFound` for an item this source owns but cannot resolve.
pub(crate) fn not_found(by: SourceId, item: &MediaRef) -> SourceError {
SourceError::NotFound {
by,
kind: item.kind(),
id: item.id().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row() -> CachedTrack {
CachedTrack {
id: qbz_media_cache::RemoteSource::Jellyfin.namespace(7),
source: "jellyfin".into(),
item_id: "srv-abc".into(),
title: "Harvester Of Sorrow".into(),
artist: "Metallica".into(),
album_artist: "Metallica".into(),
album: "...And Justice For All".into(),
album_id: "alb-1".into(),
duration_ms: 348_480,
bit_depth: Some(24),
sample_rate_hz: Some(96_000),
container: "flac".into(),
artwork_token: Some("748607df".into()),
year: Some(1988),
..Default::default()
}
}
/// Every field the queue reads, and the two conversions that are easy to
/// get backwards: ms -> s and Hz -> kHz.
#[test]
fn a_cached_row_becomes_a_queue_track_in_qbz_units() {
let qt = cached_to_queue_track(&row(), SourceId::JELLYFIN);
assert_eq!(qt.duration_secs, 348, "ms were not converted to seconds");
assert_eq!(qt.sample_rate, Some(96.0), "Hz were not converted to kHz");
assert_eq!(qt.bit_depth, Some(24));
assert!(qt.hires);
assert_eq!(qt.source.as_deref(), Some("jellyfin"));
assert!(qt.is_local, "a remote row must skip the Qobuz tier walk");
assert!(qt.streamable, "an unstreamable row is dropped from the queue");
}
/// The queue carries the NAMESPACED id and the SERVER's id, not one or the
/// other. The numeric id is what the grid published; the hint is what
/// survives a cache rebuild that renumbers rowids.
#[test]
fn the_queue_row_carries_both_identities() {
let r = row();
let qt = cached_to_queue_track(&r, SourceId::JELLYFIN);
assert_eq!(qt.id, r.id as u64);
assert_eq!(qt.source_item_id_hint.as_deref(), Some("srv-abc"));
}
/// The RAW token, never a URL: a URL embeds credentials and a size, and a
/// queue row outlives both.
#[test]
fn the_queue_row_holds_the_art_token_not_a_url() {
let qt = cached_to_queue_track(&row(), SourceId::JELLYFIN);
assert_eq!(qt.artwork_url.as_deref(), Some("748607df"));
assert!(!qt.artwork_url.unwrap().contains("http"));
}
#[test]
fn album_meta_sums_the_album_and_reads_the_first_row() {
let mut a = row();
a.duration_ms = 60_000;
let mut b = row();
b.item_id = "srv-def".into();
b.duration_ms = 90_000;
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Album, "alb-1");
let m = meta_of_rows(&[a, b], &item, SourceBadge::Jellyfin, ArtRef::None);
assert_eq!(m.title, "...And Justice For All");
assert_eq!(m.subtitle, "Metallica");
assert_eq!(m.track_count, Some(2));
assert_eq!(m.duration_secs, Some(150));
assert_eq!(m.year, Some(1988));
assert_eq!(m.quality.tier(), "hires");
assert_eq!(m.badge, SourceBadge::Jellyfin);
}
/// A lossy row must badge as mp3 even when the container name is empty and
/// only the codec says so.
#[test]
fn a_lossy_row_tiers_as_mp3_from_either_field() {
let item = MediaRef::new(SourceId::SUBSONIC, ItemKind::Album, "a");
let mut by_container = row();
by_container.container = "mp3".into();
by_container.bit_depth = None;
assert_eq!(
meta_of_rows(&[by_container], &item, SourceBadge::Subsonic, ArtRef::None)
.quality
.tier(),
"mp3"
);
let mut by_codec = row();
by_codec.container = String::new();
by_codec.codec = Some("MP3".into());
by_codec.bit_depth = None;
assert_eq!(
meta_of_rows(&[by_codec], &item, SourceBadge::Subsonic, ArtRef::None)
.quality
.tier(),
"mp3"
);
}
#[test]
fn a_track_meta_shows_the_track_title_not_the_album() {
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, "x");
let m = meta_of_rows(&[row()], &item, SourceBadge::Jellyfin, ArtRef::None);
assert_eq!(m.title, "Harvester Of Sorrow");
assert_eq!(m.kind_label, "track");
}
}
+447
View File
@@ -0,0 +1,447 @@
//! Subsonic / OpenSubsonic — Navidrome, Gonic, Airsonic, Astiga, Ampache.
//!
//! ONE source for all of them, because they speak one API. The server's brand
//! is a display detail, not an identity, and `SourceId::from_word` folds the
//! brand spellings accordingly.
//!
//! Structurally identical to [`crate::JellyfinSource`] — both read the shared
//! cache and both hand out a URL for playback. Two differences, and both come
//! from the protocol rather than from taste:
//!
//! 1. **A Subsonic cover url carries credentials.** So unlike Jellyfin's, its
//! `cache_key` must be the opaque `coverArt` id, NOT the url: the url embeds
//! the salt and the size, and keying on it would re-download a cover that is
//! already on disk the moment either changes.
//! 2. **The token is derived, not stored.** `t = md5(password + salt)`, so the
//! frontend hands over a `Credentials` rather than a bearer string.
use std::path::Path;
use std::sync::{Arc, RwLock};
use qbz_media_cache::RemoteSource;
use qbz_models::QueueTrack;
use qbz_subsonic::Credentials;
use crate::art::{ArtRef, ArtSize};
use crate::error::SourceError;
use crate::id::{ItemKind, MediaRef, RawRef, SourceId};
use crate::meta::{ItemMeta, SourceBadge};
use crate::playback::PlaybackTicket;
use crate::source::Source;
use crate::sources::remote::{cached_to_queue_track, meta_of_rows, not_found, CacheHandle};
/// The Subsonic connection, implemented by the frontend over its settings store.
pub trait SubsonicCreds: Send + Sync + 'static {
fn is_enabled(&self) -> bool;
/// `(base_url, credentials)` when the server is configured.
///
/// The frontend derives the [`Credentials`] from the stored password and
/// the install's FIXED salt — see `qbz_subsonic::Credentials` for why the
/// salt does not roll per request.
fn server(&self) -> Option<(String, Credentials)>;
}
pub struct SubsonicSource {
creds: RwLock<Option<Arc<dyn SubsonicCreds>>>,
cache: CacheHandle,
}
impl SubsonicSource {
pub fn new() -> Self {
Self {
creds: RwLock::new(None),
cache: CacheHandle::new(RemoteSource::Subsonic, SourceId::SUBSONIC),
}
}
pub fn set_creds(&self, c: Option<Arc<dyn SubsonicCreds>>) {
if let Ok(mut slot) = self.creds.write() {
*slot = c;
}
}
/// Clone-then-drop; the guard never crosses an `.await`.
fn server(&self) -> Option<(String, Credentials)> {
let creds = { self.creds.read().ok()?.clone()? };
if !creds.is_enabled() {
return None;
}
creds.server().filter(|(base, _)| !base.is_empty())
}
pub fn cache(&self) -> &CacheHandle {
&self.cache
}
fn rows(&self, item: &MediaRef) -> Vec<qbz_media_cache::CachedTrack> {
self.cache
.with(|c| match item.kind() {
ItemKind::Album => {
qbz_media_cache::album_tracks(c, RemoteSource::Subsonic, item.id())
.unwrap_or_default()
}
ItemKind::Track => {
qbz_media_cache::track_by_item_id(c, RemoteSource::Subsonic, item.id())
.ok()
.flatten()
.into_iter()
.collect()
}
_ => Vec::new(),
})
.unwrap_or_default()
}
}
impl Default for SubsonicSource {
fn default() -> Self {
Self::new()
}
}
/// POSITIVE ownership: the source word (in any of its brand spellings, which
/// `SourceId::from_word` has already folded), or the namespace bit.
pub(crate) fn recognises(raw: &RawRef) -> bool {
raw.source == Some(SourceId::SUBSONIC)
|| raw
.numeric()
.map(|n| RemoteSource::of_id(n as i64) == Some(RemoteSource::Subsonic))
.unwrap_or(false)
}
#[async_trait::async_trait]
impl Source for SubsonicSource {
fn id(&self) -> SourceId {
SourceId::SUBSONIC
}
fn claim(&self, raw: &RawRef) -> Option<Result<MediaRef, SourceError>> {
if !recognises(raw) {
return None;
}
let id = raw.id.trim();
let kind = raw.kind.unwrap_or(ItemKind::Track);
Some(match kind {
ItemKind::Album => {
if id.is_empty() {
Err(SourceError::BadIdShape {
by: SourceId::SUBSONIC,
id: id.to_string(),
why: "a subsonic album id is the server's own id, never empty",
})
} else {
Ok(MediaRef::new(SourceId::SUBSONIC, ItemKind::Album, id))
}
}
// Same two shapes as Jellyfin, same reason for preferring the hint:
// the server's id survives a cache rebuild, a rowid does not.
ItemKind::Track => {
if let Some(hint) = raw.hint_str().filter(|h| !h.is_empty()) {
Ok(MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, hint))
} else if let Some(n) = raw.numeric() {
match self
.cache
.with(|c| qbz_media_cache::track_by_id(c, n as i64))
.and_then(|r| r.ok())
.flatten()
{
Some(t) => {
Ok(MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, &t.item_id))
}
None => Err(SourceError::NotFound {
by: SourceId::SUBSONIC,
kind: ItemKind::Track,
id: raw.id.clone(),
}),
}
} else if !id.is_empty() {
Ok(MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, id))
} else {
Err(SourceError::BadIdShape {
by: SourceId::SUBSONIC,
id: id.to_string(),
why: "a subsonic track ref needs the server id or a namespaced row id",
})
}
}
other => Err(SourceError::Unsupported {
by: SourceId::SUBSONIC,
kind: other,
}),
})
}
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
let rows = self.rows(item);
if rows.is_empty() {
return Err(not_found(SourceId::SUBSONIC, item));
}
Ok(rows
.iter()
.map(|t| cached_to_queue_track(t, SourceId::SUBSONIC))
.collect())
}
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
let rows = self.rows(item);
if rows.is_empty() {
return Err(not_found(SourceId::SUBSONIC, item));
}
let art = self.artwork(item, ArtSize::Card);
Ok(meta_of_rows(&rows, item, SourceBadge::Subsonic, art))
}
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
match self
.rows(item)
.iter()
.find_map(|t| t.artwork_token.clone().filter(|s| !s.is_empty()))
{
Some(token) => self.artwork_token(&token, size),
None => ArtRef::None,
}
}
fn artwork_token(&self, token: &str, size: ArtSize) -> ArtRef {
// The token is the opaque `coverArt` id — `al-<albumId>_<hash>` for an
// album, `dc-<albumId>:<disc>_<n>` for a track. Opaque means opaque:
// store it, never parse it, never build one.
let token = token.trim();
if token.is_empty() {
return ArtRef::None;
}
let Some((base, creds)) = self.server() else {
return ArtRef::Unavailable("subsonic is not connected");
};
let px = match size {
ArtSize::Full => qbz_subsonic::IMAGE_PX_LARGE,
_ => qbz_subsonic::IMAGE_PX,
};
ArtRef::Fetch {
url: qbz_subsonic::cover_url(&base, &creds, token, px),
// THE opaque id, NOT the url. The url carries the salt and the size
// and both move; the token does not. Keying on the url would
// re-download every cover the moment either changed — and would put
// a credential in a cache key, which is its own problem.
cache_key: format!("subsonic:{token}"),
}
}
async fn playback(
&self,
item: &MediaRef,
track: &QueueTrack,
) -> Result<PlaybackTicket, SourceError> {
if item.kind() != ItemKind::Track {
return Err(SourceError::Unsupported {
by: SourceId::SUBSONIC,
kind: item.kind(),
});
}
let (base, creds) = self.server().ok_or(SourceError::NotConfigured {
by: SourceId::SUBSONIC,
why: "no Subsonic credentials configured",
})?;
// `format=raw` is the bit-perfect contract, verified md5-identical to
// the file on disk. The bare `stream.view` happened to return raw bytes
// on the bench, but only because that server had no transcoding policy
// for this client — that is CONFIGURATION, not a guarantee.
Ok(PlaybackTicket::Stream {
url: qbz_subsonic::stream_url(&base, &creds, item.id()),
play_id: track.id,
duration_secs: track.duration_secs,
start_secs: 0,
log_tag: "SUBSONIC",
})
}
fn bind_user(&self, _uid: u64, dir: &Path) {
self.cache.bind(dir);
}
fn teardown(&self) {
self.set_creds(None);
self.cache.teardown();
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Creds;
impl SubsonicCreds for Creds {
fn is_enabled(&self) -> bool {
true
}
fn server(&self) -> Option<(String, Credentials)> {
Some((
"http://nd:4533".into(),
Credentials::new("admin", "pw", "fixedsalt"),
))
}
}
/// A minimal `QueueTrack`. `qbz_models::QueueTrack` has no `Default` — its
/// `streamable` field defaults to TRUE through serde, and a derived
/// `Default` would silently make it false, so the model refuses to provide
/// one. Building it out here keeps that guarantee where it belongs.
fn qt(id: u64, duration_secs: u64) -> QueueTrack {
QueueTrack {
id,
title: String::new(),
version: None,
artist: String::new(),
album: String::new(),
album_version: None,
duration_secs,
artwork_url: None,
hires: false,
bit_depth: None,
sample_rate: None,
is_local: true,
album_id: None,
artist_id: None,
streamable: true,
source: None,
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
fn connected() -> SubsonicSource {
let s = SubsonicSource::new();
s.set_creds(Some(Arc::new(Creds)));
s
}
/// Every brand spelling folds to ONE source, or a row stamped `navidrome`
/// would be `Unclaimed` and refuse to play.
#[test]
fn every_brand_spelling_resolves_to_this_source() {
for word in ["subsonic", "navidrome", "gonic", "airsonic", "astiga"] {
assert_eq!(
SourceId::from_word(word),
Some(SourceId::SUBSONIC),
"{word} did not fold"
);
assert!(recognises(&RawRef {
source: SourceId::from_word(word),
kind: Some(ItemKind::Track),
id: "abc".into(),
..Default::default()
}));
}
}
#[test]
fn it_never_claims_another_sources_id() {
for foreign in [
(1i64 << 40) | 44_440, // Plex
RemoteSource::Jellyfin.namespace(7), // the neighbour
(1i64 << 48) + 10, // ephemeral
2954, // a local rowid
] {
assert!(
!recognises(&RawRef {
kind: Some(ItemKind::Track),
id: foreign.to_string(),
..Default::default()
}),
"claimed {foreign}"
);
}
}
#[test]
fn a_track_normalises_to_the_server_id_from_the_hint() {
let s = connected();
let m = s
.claim(&RawRef {
source: Some(SourceId::SUBSONIC),
kind: Some(ItemKind::Track),
id: RemoteSource::Subsonic.namespace(3).to_string(),
hint: Some("hpclx3k1zyDli7O1nbsII6".into()),
..Default::default()
})
.unwrap()
.unwrap();
assert_eq!(m.id(), "hpclx3k1zyDli7O1nbsII6");
}
/// THE difference from Jellyfin: the url carries credentials, so the cache
/// key must be the opaque id instead. Keying on the url would put a
/// credential in a cache key AND re-download on every salt or size change.
#[test]
fn the_cover_key_is_the_opaque_id_not_the_credentialed_url() {
let s = connected();
match s.artwork_token("al-abc_59fec8ff", ArtSize::Card) {
ArtRef::Fetch { url, cache_key } => {
assert_ne!(url, cache_key, "the url must not be the cache key here");
assert_eq!(cache_key, "subsonic:al-abc_59fec8ff");
assert!(!cache_key.contains("t="), "a credential reached the cache key");
assert!(url.contains("getCoverArt.view"));
assert!(url.contains("t="), "an unauthenticated cover request is refused");
assert!(url.contains("size=256"));
}
other => panic!("expected Fetch, got {other:?}"),
}
}
/// A track cover id contains `:` and must be encoded, not pasted.
#[test]
fn an_opaque_track_cover_id_survives_the_url() {
match connected().artwork_token("dc-abc:1_0", ArtSize::Card) {
ArtRef::Fetch { url, cache_key } => {
assert_eq!(cache_key, "subsonic:dc-abc:1_0");
assert!(url.contains("id=dc-abc%3A1_0"), "{url}");
}
other => panic!("expected Fetch, got {other:?}"),
}
}
#[test]
fn a_cover_without_a_server_is_unavailable_not_none() {
assert!(matches!(
SubsonicSource::new().artwork_token("al-x", ArtSize::Card),
ArtRef::Unavailable(_)
));
assert!(matches!(connected().artwork_token("", ArtSize::Card), ArtRef::None));
}
#[tokio::test]
async fn playback_demands_raw_and_never_caps_the_bitrate() {
let s = connected();
let item = MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, "trk-1");
let qt = qt(9, 301);
match s.playback(&item, &qt).await.unwrap() {
PlaybackTicket::Stream {
url,
play_id,
duration_secs,
log_tag,
..
} => {
assert!(url.contains("stream.view"));
assert!(url.contains("&format=raw"), "raw is the contract: {url}");
assert!(!url.contains("maxBitRate"), "a bitrate cap IS a transcode");
assert!(url.contains("&id=trk-1"));
assert!(!url.contains("pw"), "the password reached the url");
assert_eq!(play_id, 9);
assert_eq!(duration_secs, 301);
assert_eq!(log_tag, "SUBSONIC");
}
other => panic!("expected Stream, got {other:?}"),
}
}
#[tokio::test]
async fn playback_without_credentials_is_not_configured() {
let s = SubsonicSource::new();
let item = MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, "x");
let err = s.playback(&item, &qt(0, 0)).await.unwrap_err();
assert!(matches!(err, SourceError::NotConfigured { by: SourceId::SUBSONIC, .. }));
}
}