Files
qbz/crates/qbz-qt/qml/settings/MediaServerSettings.qml
T
vicrodh 3cfce8a769 feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.

Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.

CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.

THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:

1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
   not in the QML module. It resolves fine on disk, compiles nothing,
   and blanks the WHOLE settings page at runtime. All three existing
   audits passed. `qml_module_registration_audit.py` is the missing
   check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
   settings DOCUMENT, unlike Plex's toggle which reads a bridge
   property, so every mutation has to republish it — connect, enable,
   disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
   `local_rows::badge_source` had never been taught the two words and
   folded them into `_ => local`. Not cosmetic — that string is what the
   source chips filter on.

`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.

END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.

428/428.

cc
2026-08-20 10:57:28 -06:00

312 lines
13 KiB
QML

// 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 }
}
}