diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 39dadd4ce..f565f6258 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -642,6 +642,8 @@ fn main() {
"qml/settings/LocalLibrarySettings.qml",
"qml/settings/OfflineSettings.qml",
"qml/settings/PlaybackSettings.qml",
+ // ONE component instantiated twice (Jellyfin + Subsonic).
+ "qml/settings/MediaServerSettings.qml",
"qml/settings/PlexSettings.qml",
"qml/settings/SandboxSettings.qml",
"qml/settings/SettingsConfirmHost.qml",
diff --git a/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg b/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg
new file mode 100644
index 000000000..101f9dce9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg b/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg
new file mode 100644
index 000000000..c94c86194
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg
@@ -0,0 +1,38 @@
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg b/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg
new file mode 100644
index 000000000..0c27da1b9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg
@@ -0,0 +1,14 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg b/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg
new file mode 100644
index 000000000..074395676
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg
@@ -0,0 +1,71 @@
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/controls/SourceIcon.qml b/crates/qbz-qt/qml/controls/SourceIcon.qml
index da3740946..3667e01ed 100644
--- a/crates/qbz-qt/qml/controls/SourceIcon.qml
+++ b/crates/qbz-qt/qml/controls/SourceIcon.qml
@@ -30,6 +30,28 @@
// needs a tint on a multi-colour asset, so it is the one arm that uses an
// effect; on a software renderer it degrades to the untinted mark rather than
// to nothing.
+//
+// ── JELLYFIN AND SUBSONIC COME IN TWO FLAVOURS ────────────────────────────
+// Both ship a COLOUR mark and a MONOCHROME one, and `mono` picks. Cards get
+// the colour brand mark like Qobuz and Plex; dense rows get the monochrome
+// glyph, tinted with `localTint` exactly like the hard-drive.
+//
+// The monochrome pair goes through `QbzIcon`, NOT through a plain `Image`.
+// That is not a style choice — the source files are BLACK ink, so untinted
+// they are invisible on every dark theme (rendered and looked at, 2026-08-20).
+// `QbzIcon` recolours per theme at runtime with no shader involved, which is
+// also why they had to be added to `icon_tint_qt::MASTERS` with their ink
+// normalised to #ffffff: that literal IS the tint pipeline's contract.
+//
+// The Jellyfin colour asset arrived with a WHITE rounded-rect plate behind the
+// mark. It was stripped: every other brand mark here is transparent, and a
+// white tile beside them reads as a rendering bug rather than a logo.
+//
+// HONEST CAVEAT on the Subsonic mark: it is NAVIDROME's logo, and `subsonic`
+// is one source covering Navidrome, Gonic, Airsonic, Astiga and Ampache. A
+// Gonic user sees a Navidrome mark. `ping.view` reports the server's own
+// `type`, which is already persisted as `server_name`, so branching the glyph
+// later is cheap — this is a known wrongness, not an unnoticed one.
import QtQuick
import QtQuick.Effects
@@ -40,7 +62,7 @@ Item {
id: root
/// "local" | "qobuz" | "qobuz_download" | "qobuz_purchase" | "offline" |
- /// "plex" (anything else = local).
+ /// "plex" | "jellyfin" | "subsonic" (anything else = local).
///
/// `"offline"` is the Qt port's word for the Slint's `qobuz_download`
/// (src/local_rows.rs:223-229 `badge_source`, which folds `qobuz_download`
@@ -62,7 +84,14 @@ Item {
/// 22 for `qobuz_download` on `AlbumCard.slint:352` — the wordmark's
/// proportions need the extra pixels.
property int qobuzSize: -1
- /// Tint of the MONOCHROME local glyph. "secondary" = LocalAlbumView.slint:36;
+ /// Draw the MONOCHROME variant instead of the colour brand mark.
+ ///
+ /// Only Jellyfin and Subsonic have one; every other kind ignores it. Rows
+ /// set it, cards do not — a dense list of coloured logos fights the text,
+ /// which is the same reason the local glyph was never a colour mark.
+ property bool mono: false
+ /// Tint of the MONOCHROME glyphs — the local hard-drive, and the two
+ /// media-server marks when `mono`. "secondary" = LocalAlbumView.slint:36;
/// "muted" = SourceGlyph.slint:28, whose header calls the
/// muted-never-accent rule load-bearing; "white" = the card badges
/// (AlbumCard.slint:347 / TrackCard.slint:190), which sit on a near-black
@@ -73,8 +102,21 @@ Item {
|| kind === "qobuz_purchase" || kind === "offline"
readonly property bool isPlex: kind === "plex"
readonly property bool isPurchase: kind === "qobuz_purchase"
+ readonly property bool isJellyfin: kind === "jellyfin"
+ // Every brand spelling of a Subsonic server folds to ONE mark, matching
+ // `SourceId::from_word` — a row stamped "navidrome" must not fall through
+ // to the local hard-drive.
+ readonly property bool isSubsonic: kind === "subsonic" || kind === "navidrome"
+ || kind === "gonic" || kind === "airsonic" || kind === "astiga"
+ readonly property bool isMedia: root.isJellyfin || root.isSubsonic
+ /// The media marks in COLOUR (cards); `mono` sends them to `QbzIcon`.
+ readonly property bool mediaColour: root.isMedia && !root.mono
- width: root.isPlex
+ /// Media marks size with the PLEX knob: every call site that sizes Plex is
+ /// sizing "a server's logo", and the two new ones belong in that slot.
+ /// Giving them a knob of their own would mean every future call site has to
+ /// remember a third one.
+ width: (root.isPlex || root.isMedia)
? (root.plexSize < 0 ? root.glyphSize : root.plexSize)
: (root.isQobuz
? (root.qobuzSize < 0 ? root.glyphSize : root.qobuzSize)
@@ -87,9 +129,11 @@ Item {
Image {
id: mark
anchors.fill: parent
- visible: (root.isQobuz && !root.isPurchase) || root.isPlex
+ visible: (root.isQobuz && !root.isPurchase) || root.isPlex || root.mediaColour
source: root.isPlex ? root.brandDir + "plex-logo.svg"
- : root.brandDir + "qobuz-logo-filled.svg"
+ : root.isJellyfin && !root.mono ? root.brandDir + "jellyfin-logo.svg"
+ : root.isSubsonic && !root.mono ? root.brandDir + "navidrome-logo.svg"
+ : root.brandDir + "qobuz-logo-filled.svg"
fillMode: Image.PreserveAspectFit // SourceGlyph's `image-fit: contain`
// Decode at the size actually drawn: these are a 512x512 and a
// ~1000x1006 source rendered into 14-22px.
@@ -123,11 +167,15 @@ Item {
}
}
- // --- Local / anything else: the monochrome glyph, tinted ---------------
+ // --- Monochrome, tinted: the local hard-drive, and the two media marks
+ // when `mono`. All three go through QbzIcon because all three are
+ // single-ink glyphs whose ink has to follow the theme.
QbzIcon {
anchors.fill: parent
- visible: !root.isQobuz && !root.isPlex
- name: "hard-drive"
+ visible: !root.isQobuz && !root.isPlex && !root.mediaColour
+ name: root.isJellyfin ? "jellyfin"
+ : root.isSubsonic ? "navidrome"
+ : "hard-drive"
tintName: root.localTint
}
}
diff --git a/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml b/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml
index c4bcf72a5..bb30f5a5a 100644
--- a/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml
+++ b/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml
@@ -174,6 +174,38 @@ Column {
Item { width: 1; height: 28 }
// ================================ PLEX ===============================
+ // The media servers, ABOVE Plex: Plex is the legacy integration and the
+ // one whose cache has not folded into the shared mirror yet, so the two
+ // newer ones lead. One component twice — every difference between the
+ // protocols is a property here rather than a second file.
+ MediaServerSettings {
+ width: parent.width
+ confirmHost: root.confirmHost
+ server: "jellyfin"
+ title: "Jellyfin"
+ state: (root.doc.library || ({})).jellyfin || ({})
+ subtitle: QbzSession.tr("Stream your own Jellyfin library, bit-perfect.", QbzSession.trRev)
+ urlPlaceholder: "http://192.168.0.10:8096"
+ // Jellyfin really can be tested without credentials.
+ testHint: QbzSession.tr("Checked before you sign in.", QbzSession.trRev)
+ credentialNote: QbzSession.tr("Jellyfin issues a token; your password is not stored.", QbzSession.trRev)
+ syncCost: QbzSession.tr("A first sync takes about a minute per 5,000 tracks.", QbzSession.trRev)
+ }
+
+ MediaServerSettings {
+ width: parent.width
+ confirmHost: root.confirmHost
+ server: "subsonic"
+ title: "Subsonic"
+ state: (root.doc.library || ({})).subsonic || ({})
+ subtitle: QbzSession.tr("Navidrome, Gonic, Airsonic and other Subsonic servers.", QbzSession.trRev)
+ urlPlaceholder: "http://192.168.0.10:4533"
+ // Subsonic has NO credential-free endpoint, so the test proves less.
+ testHint: QbzSession.tr("Only checks that a Subsonic server answers.", QbzSession.trRev)
+ credentialNote: QbzSession.tr("Subsonic has no session, so your password is stored to sign each request.", QbzSession.trRev)
+ syncCost: QbzSession.tr("Syncing is fast — a few seconds for a large library.", QbzSession.trRev)
+ }
+
PlexSettings {
width: parent.width
doc: root.doc
diff --git a/crates/qbz-qt/qml/settings/MediaServerSettings.qml b/crates/qbz-qt/qml/settings/MediaServerSettings.qml
new file mode 100644
index 000000000..986c5f3d1
--- /dev/null
+++ b/crates/qbz-qt/qml/settings/MediaServerSettings.qml
@@ -0,0 +1,311 @@
+// Settings > Local Library > a MEDIA SERVER (Jellyfin or Subsonic).
+//
+// ONE component, instantiated twice, because it is one form twice: address,
+// account, password, connect, library sweep, disconnect. Everything the two
+// protocols disagree about is a property set by the caller, so the differences
+// are visible in ONE place (`LocalLibrarySettings.qml`) instead of being spread
+// across two near-identical files that drift.
+//
+// Wiring mirrors PlexSettings.qml: the persisted state rides the settings
+// document (`settings_qt/library.rs`), the actions go through the QbzLocal
+// bridge, and THE CREDENTIAL NEVER LEAVES RUST — the panel gets
+// `hasCredential`, never the token or the password.
+//
+// ── WHAT DIFFERS BETWEEN THE TWO, AND WHY THE USER SEES IT ────────────────
+//
+// **"Test" means different things.** Jellyfin has an unauthenticated probe
+// (`/System/Info/Public`), so its test genuinely checks the ADDRESS before the
+// user types a password. Subsonic has no such endpoint — `ping.view` needs
+// credentials — so its test can only confirm that something Subsonic-shaped
+// answered. `testHint` says which, because a button that claims more than it
+// checked is worse than no button.
+//
+// **The password is kept, or it is not.** Jellyfin issues a token and forgets
+// the password. Subsonic has no session and re-derives `md5(password + salt)`
+// on every request, so the password is stored. That is the protocol's choice,
+// not ours, and `credentialNote` tells the user rather than leaving them to
+// wonder what is on disk.
+//
+// **A sweep costs very different amounts.** 45.8 s for 4924 Jellyfin tracks
+// (server-side media-info hydration, the only way to get bit depth) against
+// 0.81 s for 6678 Subsonic ones. `syncCost` sets expectations before the click,
+// which is the difference between "it is working" and "it is frozen".
+
+import QtQuick
+import com.blitzfc.qbz
+import "../controls"
+import "../theme"
+
+Column {
+ id: root
+
+ /// "jellyfin" | "subsonic" — the word every bridge call is keyed on.
+ required property string server
+ /// Display name in the header ("Jellyfin", "Subsonic").
+ required property string title
+ /// One line under the title.
+ required property string subtitle
+ /// Placeholder for the address field.
+ required property string urlPlaceholder
+ /// What this server's "Test" actually proves. See the header.
+ required property string testHint
+ /// What is written to disk for this protocol. See the header.
+ required property string credentialNote
+ /// What a first sweep will cost, in the user's terms.
+ required property string syncCost
+ /// The slice of the settings document for this server.
+ required property var state
+
+ /// The view-level SettingsConfirmHost, handed down by
+ /// LocalLibrarySettings. Null in previews; the disconnect falls back to
+ /// acting directly.
+ property var confirmHost: null
+
+ // In-progress field values. The fields commit on Enter / focus loss; the
+ // buttons submit whatever is typed RIGHT NOW.
+ property string urlInput: ""
+ property string userInput: ""
+ property string passInput: ""
+
+ /// What an action would actually send: freshly typed text if there is any,
+ /// else the persisted value. Single-sourced so the gate and the click judge
+ /// the SAME string — judging the field while sending the fallback is how a
+ /// gate ends up decorative (PlexSettings.qml learned this one).
+ readonly property string effectiveUrl:
+ root.urlInput !== "" ? root.urlInput : (root.state.serverUrl || "")
+ readonly property string effectiveUser:
+ root.userInput !== "" ? root.userInput : (root.state.username || "")
+ readonly property bool canConnect:
+ root.effectiveUrl !== "" && root.effectiveUser !== "" && root.passInput !== ""
+
+ QbzTheme { id: theme }
+
+ width: parent ? parent.width : 0
+ spacing: 4
+
+ // ============================== header ================================
+ Item {
+ width: parent.width
+ height: 44
+
+ Column {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - 120
+ spacing: 2
+ Row {
+ spacing: 8
+ SourceIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ kind: root.server
+ glyphSize: 18
+ plexSize: 18
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.title
+ color: theme.textPrimary
+ font.pixelSize: 14
+ font.weight: Font.DemiBold
+ }
+ // What the server called itself, once we have talked to it.
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: (root.state.serverName || "") !== ""
+ text: "· " + (root.state.serverName || "")
+ color: theme.textMuted
+ font.pixelSize: 12
+ }
+ }
+ Text {
+ width: parent.width
+ text: root.subtitle
+ color: theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+ Rectangle {
+ visible: root.state.enabled === true
+ width: 28
+ height: 28
+ radius: theme.radiusSm
+ anchors.verticalCenter: parent.verticalCenter
+ color: chevArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: root.state.collapsed === true ? "chevron-right" : "chevron-down"
+ width: 18
+ height: 18
+ tintName: "secondary"
+ }
+ MouseArea {
+ id: chevArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.settingsBool(
+ root.server + "-collapse", root.state.collapsed !== true)
+ }
+ }
+ QbzToggle {
+ anchors.verticalCenter: parent.verticalCenter
+ checked: root.state.enabled === true
+ onToggled: function (v) { QbzLocal.mediaSetEnabled(root.server, v) }
+ }
+ }
+ }
+
+ // =============================== body =================================
+ Column {
+ visible: root.state.enabled === true && root.state.collapsed !== true
+ width: parent.width
+ spacing: 4
+
+ Item { width: 1; height: 10 }
+
+ SettingRow {
+ label: QbzSession.tr("Server address", QbzSession.trRev)
+ description: root.testHint
+ Row {
+ spacing: 8
+ QbzLineEdit {
+ width: 240
+ text: root.state.serverUrl || ""
+ placeholder: root.urlPlaceholder
+ onEdited: function (v) { root.urlInput = v }
+ onCommitted: function (v) { root.urlInput = v }
+ }
+ IconTextButton {
+ anchors.verticalCenter: parent.verticalCenter
+ label: QbzSession.tr("Test", QbzSession.trRev)
+ hasIcon: false
+ btnEnabled: root.effectiveUrl !== ""
+ onClicked: QbzLocal.mediaTest(root.server, root.effectiveUrl)
+ }
+ }
+ }
+
+ SettingRow {
+ label: QbzSession.tr("Username", QbzSession.trRev)
+ QbzLineEdit {
+ width: 240
+ text: root.state.username || ""
+ placeholder: QbzSession.tr("Account name", QbzSession.trRev)
+ onEdited: function (v) { root.userInput = v }
+ onCommitted: function (v) { root.userInput = v }
+ }
+ }
+
+ SettingRow {
+ label: QbzSession.tr("Password", QbzSession.trRev)
+ description: root.credentialNote
+ QbzLineEdit {
+ width: 240
+ // NEVER prefilled: the document does not carry it, and asking
+ // for it again is the honest cost of not shipping it to QML.
+ text: ""
+ isPassword: true
+ placeholder: root.state.hasCredential === true
+ ? QbzSession.tr("Stored — type to replace", QbzSession.trRev)
+ : QbzSession.tr("Password", QbzSession.trRev)
+ onEdited: function (v) { root.passInput = v }
+ onCommitted: function (v) { root.passInput = v }
+ }
+ }
+
+ // --- Connect ------------------------------------------------------
+ SettingRow {
+ label: QbzSession.tr("Connection", QbzSession.trRev)
+ description: root.state.hasCredential === true
+ ? QbzSession.tr("Connected. Type a password to reconnect.", QbzSession.trRev)
+ : QbzSession.tr("Sign in to sync this server's library.", QbzSession.trRev)
+ QbzPrimaryButton {
+ label: QbzSession.tr("Connect", QbzSession.trRev)
+ btnEnabled: root.canConnect && !QbzLocal.mediaSyncing
+ btnHeight: 34
+ onClicked: QbzLocal.mediaConnect(
+ root.server, root.effectiveUrl, root.effectiveUser, root.passInput)
+ }
+ }
+
+ // --- Library sweep ------------------------------------------------
+ SettingRow {
+ visible: root.state.hasCredential === true
+ label: QbzSession.tr("Library", QbzSession.trRev)
+ // The count is what tells the user the sweep did anything; the cost
+ // line is what stops a 45-second Jellyfin sweep reading as a hang.
+ description: (root.state.cachedTracks > 0
+ ? QbzSession.tr("%1 tracks cached.", QbzSession.trRev)
+ .replace("%1", root.state.cachedTracks)
+ : QbzSession.tr("Not synced yet.", QbzSession.trRev)) + " " + root.syncCost
+ Row {
+ spacing: 8
+ // The progress text replaces the buttons while a sweep runs —
+ // one control, one state, and no button to click twice.
+ Text {
+ visible: QbzLocal.mediaSyncing
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzLocal.mediaSyncProgress !== ""
+ ? QbzSession.tr("Syncing…", QbzSession.trRev) + " " + QbzLocal.mediaSyncProgress
+ : QbzSession.tr("Syncing…", QbzSession.trRev)
+ color: theme.textSecondary
+ font.pixelSize: 12
+ }
+ IconTextButton {
+ visible: !QbzLocal.mediaSyncing
+ anchors.verticalCenter: parent.verticalCenter
+ label: QbzSession.tr("Sync now", QbzSession.trRev)
+ hasIcon: false
+ onClicked: QbzLocal.mediaSync(root.server, false)
+ }
+ // FULL is separate because it is the expensive one and the
+ // answer to "the delta missed something", not the default.
+ IconTextButton {
+ visible: !QbzLocal.mediaSyncing && root.state.lastSyncAt > 0
+ anchors.verticalCenter: parent.verticalCenter
+ label: QbzSession.tr("Full re-sync", QbzSession.trRev)
+ hasIcon: false
+ onClicked: QbzLocal.mediaSync(root.server, true)
+ }
+ }
+ }
+
+ // --- Danger zone --------------------------------------------------
+ SettingRow {
+ visible: root.state.hasCredential === true
+ label: QbzSession.tr("Disconnect", QbzSession.trRev)
+ description: QbzSession.tr(
+ "Signs out and removes this server's tracks from your library.",
+ QbzSession.trRev)
+ IconTextButton {
+ label: QbzSession.tr("Disconnect", QbzSession.trRev)
+ hasIcon: false
+ danger: true
+ onClicked: {
+ // CONFIRMED, unlike the master toggle: the toggle only
+ // hides the rows and is one click to undo, while this
+ // purges the cache and costs a full re-sweep to get back.
+ if (root.confirmHost) {
+ root.confirmHost.ask(
+ QbzSession.tr("Disconnect this server?", QbzSession.trRev),
+ QbzSession.tr(
+ "Its tracks are removed from your library. Your files are not touched.",
+ QbzSession.trRev),
+ QbzSession.tr("Disconnect", QbzSession.trRev),
+ function () { QbzLocal.mediaDisconnect(root.server) })
+ } else {
+ QbzLocal.mediaDisconnect(root.server)
+ }
+ }
+ }
+ }
+
+ Item { width: 1; height: 6 }
+ }
+}
diff --git a/crates/qbz-qt/qml/views/library/FeedListRow.qml b/crates/qbz-qt/qml/views/library/FeedListRow.qml
index 65abe519a..8148b763c 100644
--- a/crates/qbz-qt/qml/views/library/FeedListRow.qml
+++ b/crates/qbz-qt/qml/views/library/FeedListRow.qml
@@ -257,6 +257,10 @@ Rectangle {
visible: (feedRow.item.source || "") !== ""
&& feedRow.item.source !== "qobuz"
kind: feedRow.item.source || ""
+ // A dense ROW: the media marks draw monochrome and tinted, like the
+ // hard-drive beside them. Colour logos are for cards — a list of
+ // them fights the text it labels.
+ mono: true
glyphSize: 15
plexSize: 16
qobuzSize: 16
diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml
index 64954d77c..caac6e0e5 100644
--- a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml
+++ b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml
@@ -179,6 +179,10 @@ Rectangle {
// benefit of the source chips. Without this the LIST mode of
// the very same album loses the gold mark the GRID shows.
kind: root.item.sourceRaw || root.item.source || ""
+ // A dense ROW: the media marks draw monochrome and tinted, like the
+ // hard-drive beside them. Colour logos are for cards — a list of
+ // them fights the text it labels.
+ mono: true
// .slint:319 — local 15, the marks 16; muted local tint.
glyphSize: 15
plexSize: 16
diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
index c9a1d810f..4ea3ce5dd 100644
--- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
+++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
@@ -180,6 +180,10 @@ Item {
// `source` folds into "offline" so the source chips still filter it.
// A purchased track's row is gold; a plain download's is not.
kind: root.item.sourceRaw || root.item.source || ""
+ // A dense ROW: the media marks draw monochrome and tinted, like the
+ // hard-drive beside them. Colour logos are for cards — a list of
+ // them fights the text it labels.
+ mono: true
// .slint:722 — local 15, the marks 16. `localTint` muted is the rule
// SourceGlyph.slint:6-8 calls load-bearing (never accent).
glyphSize: 15
diff --git a/crates/qbz-qt/qml/views/local/VersionPicker.qml b/crates/qbz-qt/qml/views/local/VersionPicker.qml
index 5e5982833..c0c8da880 100644
--- a/crates/qbz-qt/qml/views/local/VersionPicker.qml
+++ b/crates/qbz-qt/qml/views/local/VersionPicker.qml
@@ -38,6 +38,10 @@ Rectangle {
SourceIcon {
anchors.verticalCenter: parent.verticalCenter
kind: root.currentVersion.source || "local"
+ // A dense ROW: the media marks draw monochrome and tinted, like the
+ // hard-drive beside them. Colour logos are for cards — a list of
+ // them fights the text it labels.
+ mono: true
// No size overrides on purpose: LocalAlbumView.slint:24-26 draws
// ALL THREE kinds at a flat 14px here, which is SourceIcon's
// default (the row glyphs are the ones that grow the marks).
@@ -86,6 +90,7 @@ Rectangle {
SourceIcon {
anchors.verticalCenter: parent.verticalCenter
kind: opt.modelData.source || "local"
+ mono: true
}
Text {
width: parent.width - 22 - (opt.index === root.current ? 20 : 0)
diff --git a/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml b/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml
index 604b55372..af393d569 100644
--- a/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml
+++ b/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml
@@ -430,6 +430,10 @@ Rectangle {
SourceIcon {
anchors.centerIn: parent
kind: root.candidate.source || ""
+ // A dense ROW: the media marks draw monochrome and tinted, like the
+ // hard-drive beside them. Colour logos are for cards — a list of
+ // them fights the text it labels.
+ mono: true
// Row glyph — SourceGlyph.slint:31: 15px, and the three
// Qobuz kinds one pixel larger (the wordmark's
// proportions). Explicit since the size rule became a
diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml
index b95bb6a60..1dac4a961 100644
--- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml
+++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml
@@ -381,6 +381,10 @@ Rectangle {
x: 0
anchors.verticalCenter: parent.verticalCenter
kind: root.sourceKind
+ // A dense ROW: the media marks draw monochrome and tinted, like the
+ // hard-drive beside them. Colour logos are for cards — a list of
+ // them fights the text it labels.
+ mono: true
// Row glyph, so SourceGlyph.slint's numbers (:31 — 15px, the
// three Qobuz kinds 16px, muted local tint), not the version
// picker's flat-14 defaults.
diff --git a/crates/qbz-qt/src/icon_tint_qt.rs b/crates/qbz-qt/src/icon_tint_qt.rs
index 1f128b78b..a4598da13 100644
--- a/crates/qbz-qt/src/icon_tint_qt.rs
+++ b/crates/qbz-qt/src/icon_tint_qt.rs
@@ -197,6 +197,16 @@ const MASTERS: &[(&str, &str)] = &[
// differently" reads better answered here.
("gandalf", include_str!("../qml/assets/icons/primary/gandalf.svg")),
("globe", include_str!("../qml/assets/icons/primary/globe.svg")),
+ // ── Media-server BRAND marks, monochrome ──────────────────────────────
+ // Unlike the rest of this table these are logos, not UI glyphs, and they
+ // are here for one reason: they arrived as BLACK ink and are invisible on
+ // every dark theme untinted (rendered and looked at, 2026-08-20). This
+ // pipeline is the port's only shader-free way to recolour an SVG per
+ // theme, so the marks were normalised to the #ffffff master ink and joined
+ // it. Their COLOUR variants stay in `assets/brand/` and are drawn untinted
+ // like Plex's and Qobuz's — see `controls/SourceIcon.qml`.
+ ("jellyfin", include_str!("../qml/assets/icons/primary/jellyfin.svg")),
+ ("navidrome", include_str!("../qml/assets/icons/primary/navidrome.svg")),
("grip-vertical", include_str!("../qml/assets/icons/primary/grip-vertical.svg")),
("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")),
("heart-filled", include_str!("../qml/assets/icons/primary/heart-filled.svg")),
diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs
index 698bb392a..e913dfbe4 100644
--- a/crates/qbz-qt/src/local_bridge.rs
+++ b/crates/qbz-qt/src/local_bridge.rs
@@ -755,6 +755,7 @@ impl qbz_local::QbzLocal {
match crate::media_servers_qt::connect(kind, &url, &user, &pass).await {
Ok(()) => {
crate::toast_qt::success(qbz_i18n::t("Connected"));
+ crate::settings_qt::publish_snapshot().await;
run_media_sync(kind, true).await;
}
Err(e) => crate::toast_qt::error(e),
@@ -770,6 +771,12 @@ impl qbz_local::QbzLocal {
let mut cfg = crate::media_servers_qt::get(kind);
cfg.enabled = enabled;
crate::media_servers_qt::put(kind, &cfg);
+ // The panel reads `state.enabled` off the SETTINGS DOCUMENT, not
+ // off a bridge property the way Plex's toggle does — so a write
+ // that does not republish leaves the switch visually stuck in its
+ // old position while the store underneath has already changed
+ // (caught by driving the real window, 2026-08-20).
+ crate::settings_qt::publish_snapshot().await;
// The union IS the query — the grid/tracks/badges must re-run.
reload_browse();
});
@@ -795,6 +802,7 @@ impl qbz_local::QbzLocal {
};
crate::spawn(async move {
crate::media_servers_qt::disconnect(kind);
+ crate::settings_qt::publish_snapshot().await;
// Purge the rows too: leaving them would keep a signed-out
// server's music in the grid until something else cleared it,
// and the master-toggle path deliberately does NOT purge (so it
@@ -1105,6 +1113,9 @@ async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind,
r.pruned,
r.total
);
+ // The sweep stamped `last_sync_at` / `last_sync_tracks`; without a
+ // republish the panel keeps reporting "Not synced yet".
+ crate::settings_qt::publish_snapshot().await;
reload_browse();
}
Err(e) => crate::toast_qt::error(e),
diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs
index 19717e0fd..41645fa80 100644
--- a/crates/qbz-qt/src/local_playback.rs
+++ b/crates/qbz-qt/src/local_playback.rs
@@ -688,6 +688,44 @@ pub async fn play_single_from_source(runtime: &Runtime, track_id: u64, source: &
.ok()
.flatten()
}
+ // The media servers resolve through the SEAM, not through a
+ // `LocalTrack`: their rows live in the shared remote cache and have no
+ // `library.db` row to mint one from. `registry.tracks_of` claims the
+ // namespaced id and hands back a queue row directly, which is the
+ // shape this function was building by hand for the other sources.
+ "jellyfin" | "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => {
+ let raw = qbz_source::RawRef {
+ source: qbz_source::SourceId::from_word(source),
+ kind: Some(qbz_source::ItemKind::Track),
+ id: track_id.to_string(),
+ is_local: Some(true),
+ ..Default::default()
+ };
+ return match qbz_source::registry().tracks_of(&raw).await {
+ Ok(tracks) if !tracks.is_empty() => {
+ let first = tracks[0].clone();
+ if crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None)
+ .await
+ .is_none()
+ {
+ log::warn!("[qbz-qt] {source} play: the queue was filtered to nothing");
+ return false;
+ }
+ crate::playback_qt::publish_queue(runtime).await;
+ let played = play_audible(runtime, &first).await;
+ crate::playback_qt::refresh_now_playing(runtime).await;
+ played
+ }
+ Ok(_) => {
+ log::warn!("[qbz-qt] {source} play: track {track_id} resolved to nothing");
+ false
+ }
+ Err(e) => {
+ log::warn!("[qbz-qt] {source} play: track {track_id} not resolvable: {e}");
+ false
+ }
+ };
+ }
other => {
log::warn!("[qbz-qt] play: unknown source {other:?} for track {track_id}");
return false;
diff --git a/crates/qbz-qt/src/local_rows.rs b/crates/qbz-qt/src/local_rows.rs
index 0d8204d59..ac7fb7d16 100644
--- a/crates/qbz-qt/src/local_rows.rs
+++ b/crates/qbz-qt/src/local_rows.rs
@@ -249,6 +249,16 @@ pub fn artist_key(name: &str) -> String {
pub fn badge_source(raw: Option<&str>) -> String {
match raw {
Some("plex") => "plex".into(),
+ // The media servers. Missing here they fell into the `_` arm and every
+ // Jellyfin album in the grid drew the LOCAL HARD-DRIVE glyph — which is
+ // not a cosmetic slip: the badge is what tells a user whose disk a
+ // track lives on, and the source chips filter on this exact string.
+ // Caught by looking at the window, 2026-08-20; no test and no audit
+ // could see it, because folding to "local" is a perfectly valid answer
+ // for a word this function was never taught.
+ Some("jellyfin") => "jellyfin".into(),
+ Some("subsonic") | Some("navidrome") | Some("gonic") | Some("airsonic")
+ | Some("astiga") => "subsonic".into(),
Some("qobuz_download") | Some("qobuz_purchase") => "offline".into(),
_ => "local".into(),
}
diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs
index 22c2721c2..71b3bf1f4 100644
--- a/crates/qbz-qt/src/settings_qt.rs
+++ b/crates/qbz-qt/src/settings_qt.rs
@@ -2378,6 +2378,22 @@ pub async fn settings_bool(runtime: &Arc>, key: &str,
save_pref("plex_ui_collapsed", serde_json::json!(value));
Ok(Apply::None)
}
+ // The media servers keep their collapse state in their OWN store
+ // rather than in ui_prefs, because the whole row lives there — a
+ // second home for one boolean is how the two drift out of step on a
+ // user switch.
+ "jellyfin-collapse" | "subsonic-collapse" => {
+ use qbz_app::settings::media_servers::MediaServerKind;
+ let kind = if key.starts_with("jellyfin") {
+ MediaServerKind::Jellyfin
+ } else {
+ MediaServerKind::Subsonic
+ };
+ let mut cfg = crate::media_servers_qt::get(kind);
+ cfg.ui_collapsed = value;
+ crate::media_servers_qt::put(kind, &cfg);
+ Ok(Apply::None)
+ }
other => {
log::warn!("[qbz-qt] unknown settings bool key: {other}");
return;
diff --git a/crates/qbz-qt/src/settings_qt/library.rs b/crates/qbz-qt/src/settings_qt/library.rs
index 62873042d..5006d2a6e 100644
--- a/crates/qbz-qt/src/settings_qt/library.rs
+++ b/crates/qbz-qt/src/settings_qt/library.rs
@@ -61,6 +61,61 @@ pub struct PlexFields {
pub collapsed: bool,
}
+/// One media server's panel state.
+///
+/// The credential itself NEVER leaves Rust — the panel gets `hasCredential`
+/// and nothing else, the same discipline `PlexFields` keeps with its token.
+/// A password round-tripping through a QML string property would sit in the
+/// scene graph's memory and in any JSON the panel logged.
+/// Read one media server's persisted settings into the panel's shape.
+fn media_fields(
+ kind: qbz_app::settings::media_servers::MediaServerKind,
+) -> MediaServerFields {
+ let s = crate::media_servers_qt::get(kind);
+ MediaServerFields {
+ enabled: s.enabled,
+ collapsed: s.ui_collapsed,
+ server_url: s.base_url.clone(),
+ // Jellyfin stores the USER ID here (every /Items call keys on it), so
+ // it is not a name worth prefilling. Subsonic stores the real account
+ // name and sends it on every request.
+ username: match kind {
+ qbz_app::settings::media_servers::MediaServerKind::Subsonic => s.username.clone(),
+ _ => String::new(),
+ },
+ has_credential: s.is_configured(kind),
+ server_name: s.server_name.clone(),
+ cached_tracks: s.last_sync_tracks,
+ last_sync_at: s.last_sync_at,
+ }
+}
+
+#[derive(Clone, Default, Serialize)]
+pub struct MediaServerFields {
+ pub enabled: bool,
+ pub collapsed: bool,
+ /// The persisted address — prefills the field.
+ #[serde(rename = "serverUrl")]
+ pub server_url: String,
+ /// The account name. Not secret, and prefilling it saves a retype.
+ pub username: String,
+ /// A usable credential is stored (a Jellyfin token, or a Subsonic
+ /// password). What KIND it is differs per protocol and the panel does not
+ /// need to know.
+ #[serde(rename = "hasCredential")]
+ pub has_credential: bool,
+ /// What the server called itself at connect time ("blitzmini 10.11.11",
+ /// "navidrome 1.16.1"). Empty until a successful connect.
+ #[serde(rename = "serverName")]
+ pub server_name: String,
+ /// Tracks cached by the last completed sweep.
+ #[serde(rename = "cachedTracks")]
+ pub cached_tracks: i64,
+ /// Unix seconds of that sweep; 0 = never.
+ #[serde(rename = "lastSyncAt")]
+ pub last_sync_at: i64,
+}
+
#[derive(Clone, Default, Serialize)]
pub struct Snapshot {
pub folders: Vec,
@@ -75,6 +130,10 @@ pub struct Snapshot {
/// Inline result of the last add/remove (empty when there is nothing to say).
pub status: String,
pub plex: PlexFields,
+ /// Jellyfin and Subsonic. One shape twice, because the panel is one form
+ /// twice — see `qbz_app::settings::media_servers`.
+ pub jellyfin: MediaServerFields,
+ pub subsonic: MediaServerFields,
/// The per-folder settings modal (`LibFolderEditModal.slint`). Rides the
/// settings document rather than a bridge of its own — it is a surface of
/// this panel and every one of its actions already lands in this module.
@@ -250,6 +309,12 @@ pub fn snapshot() -> Snapshot {
.clone(),
clearing: CLEARING.load(Ordering::SeqCst),
status: STATUS.lock().unwrap_or_else(|e| e.into_inner()).clone(),
+ jellyfin: media_fields(
+ qbz_app::settings::media_servers::MediaServerKind::Jellyfin,
+ ),
+ subsonic: media_fields(
+ qbz_app::settings::media_servers::MediaServerKind::Subsonic,
+ ),
plex: PlexFields {
has_token: !plex_cfg.token.trim().is_empty(),
server_url: plex_cfg.base_url,
diff --git a/scripts/qt-run.sh b/scripts/qt-run.sh
index a0329cbba..c049860ec 100755
--- a/scripts/qt-run.sh
+++ b/scripts/qt-run.sh
@@ -141,7 +141,12 @@ fi
if [[ "${NO_AUDIT:-0}" != 1 ]]; then
if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&1; then
audit_abs="$(pwd)/${QT_CRATE}"
- for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_audit.py; do
+ # `qml_module_registration_audit.py` is the newest and it exists because the
+ # other three could not see the failure it catches: a .qml on disk that is
+ # missing from build.rs is not in the QML MODULE, so it resolves fine on
+ # disk, compiles nothing, and blanks a whole page at runtime with
+ # " is not a type" (2026-08-20, the media-server panel).
+ for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_audit.py qml_module_registration_audit.py; do
[[ -r "${AUDIT_DIR}/${a}" ]] || { warn "audit ${a} not found — skipped"; continue; }
if ! python3 "${AUDIT_DIR}/${a}" "${audit_abs}"; then
die "${a} FAILED — fix it before burning a build (QML resolves lazily; this is what cargo cannot tell you)."