Files
qbz/crates/qbz-qt/qml/settings/MediaServerSettings.qml
T
vicrodh 75b8eee1c6 feat(media): add Jellyfin Quick Connect and harden remote connections
Add cancellable Quick Connect pairing and shared connection guards for Jellyfin and Subsonic. Bound connection attempts, validate library access before saving credentials, and report per-provider connection and synchronization status.

cx
2026-09-09 17:12:45 -06:00

405 lines
19 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 {
property bool kioskHost: false
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.trim() !== "" && root.effectiveUser.trim() !== "" && root.passInput !== ""
readonly property var operation: (JSON.parse(QbzLocal.mediaStatus || "{}"))[root.server] || ({})
readonly property bool busy: operation.busy === true || operation.syncing === true
readonly property bool pairing: root.server === "jellyfin" && root.busy
&& (operation.phase || "").indexOf("pairing") === 0
function cancelPairing() {
if (root.server === "jellyfin") QbzLocal.mediaCancelQuickConnect()
}
onVisibleChanged: { if (!visible) cancelPairing() }
Component.onDestruction: cancelPairing()
readonly property string statusText: {
// Each panel observes its own provider, including when both sync at once.
if (operation.syncing === true)
return QbzSession.tr("Connected. Syncing library…", QbzSession.trRev)
switch (operation.phase || "") {
case "pairing-start": return QbzSession.tr("Requesting Quick Connect code…", QbzSession.trRev)
case "pairing-waiting": return QbzSession.tr("Waiting for authorization…", QbzSession.trRev)
case "pairing-verifying": return QbzSession.tr("Checking library access…", QbzSession.trRev)
case "testing": return QbzSession.tr("Checking server…", QbzSession.trRev)
case "authenticating": return QbzSession.tr("Signing in…", QbzSession.trRev)
case "verifying": return QbzSession.tr("Checking library access…", QbzSession.trRev)
case "reachable": return root.server === "jellyfin"
? QbzSession.tr("Server reachable. Sign in to connect.", QbzSession.trRev)
: QbzSession.tr("Server reachable. Credentials have not been checked.", QbzSession.trRev)
case "syncing": return QbzSession.tr("Connected. Syncing library…", QbzSession.trRev)
case "ready": return QbzSession.tr("Connected. Library is up to date.", QbzSession.trRev)
case "connected": return QbzSession.tr("Connected", QbzSession.trRev)
case "refreshing": return QbzSession.tr("Connected. The server is refreshing its library.", QbzSession.trRev)
case "failed": case "sync-failed": return operation.error || ""
default: return root.state.hasCredential === true
? QbzSession.tr("Saved connection. Sync to check access.", QbzSession.trRev)
: QbzSession.tr("Sign in to sync this server's library.", QbzSession.trRev)
}
}
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: root.kioskHost ? (14) * 1.2 : (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: root.kioskHost ? (12) * 1.2 : (12)
}
}
Text {
width: parent.width
text: root.subtitle
color: theme.textMuted
font.pixelSize: root.kioskHost ? (12) * 1.2 : (12)
wrapMode: Text.WordWrap
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
id: collapseButton
visible: root.state.enabled === true
width: root.kioskHost ? 44 : 28
height: root.kioskHost ? 44 : 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: chevArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: visible && enabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: root.title
Accessible.onPressAction: collapseButton.activate()
function activate() {
QbzBridge.settingsBool(
root.server + "-collapse", root.state.collapsed !== true)
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
collapseButton.activate()
event.accepted = true
}
}
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
onPressed: collapseButton.forceActiveFocus()
onClicked: collapseButton.activate()
}
}
QbzToggle { kioskHost: root.kioskHost;
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
onVisibleChanged: { if (!visible) root.cancelPairing() }
width: parent.width
spacing: 4
Item { width: 1; height: 10 }
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Server address", QbzSession.trRev)
description: root.testHint
Row {
spacing: 8
QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
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.trim() !== "" && !root.busy
onClicked: QbzLocal.mediaTest(root.server, root.effectiveUrl)
}
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Username", QbzSession.trRev)
QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
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 { kioskHost: root.kioskHost;
label: QbzSession.tr("Password", QbzSession.trRev)
description: root.credentialNote
QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
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 { kioskHost: root.kioskHost;
label: QbzSession.tr("Connection", QbzSession.trRev)
description: root.statusText
QbzPrimaryButton {
label: QbzSession.tr("Connect", QbzSession.trRev)
btnEnabled: root.canConnect && !root.busy
btnHeight: 34
onClicked: QbzLocal.mediaConnect(
root.server, root.effectiveUrl, root.effectiveUser, root.passInput)
}
}
SettingRow { kioskHost: root.kioskHost;
id: quickConnectRow
objectName: "quickConnectRow"
visible: root.server === "jellyfin"
label: QbzSession.tr("Quick Connect", QbzSession.trRev)
description: QbzSession.tr("Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes.", QbzSession.trRev)
Row {
spacing: 12
Text {
objectName: "quickConnectCode"
anchors.verticalCenter: parent.verticalCenter
visible: root.pairing && (root.operation.pairing_code || "") !== ""
text: root.operation.pairing_code || ""
color: theme.textPrimary
font.pixelSize: root.kioskHost ? 28 : 24
font.weight: Font.DemiBold
Accessible.name: text
}
IconTextButton {
objectName: "quickConnectButton"
anchors.verticalCenter: parent.verticalCenter
label: root.pairing ? QbzSession.tr("Cancel", QbzSession.trRev)
: QbzSession.tr("Quick Connect", QbzSession.trRev)
hasIcon: false
btnEnabled: root.pairing || (!root.busy && root.effectiveUrl.trim() !== "")
onClicked: {
if (root.pairing) root.cancelPairing()
else QbzLocal.mediaQuickConnect(root.effectiveUrl)
}
}
}
}
// --- Library sweep ------------------------------------------------
SettingRow { kioskHost: root.kioskHost;
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: root.operation.syncing === true
anchors.verticalCenter: parent.verticalCenter
text: (root.operation.progress || "") !== ""
? QbzSession.tr("Syncing…", QbzSession.trRev) + " " + (root.operation.progress || "")
: QbzSession.tr("Syncing…", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (12) * 1.2 : (12)
}
IconTextButton {
visible: root.operation.syncing !== true
anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Sync now", QbzSession.trRev)
hasIcon: false
btnEnabled: !root.busy
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: root.operation.syncing !== true && root.state.lastSyncAt > 0
anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Full re-sync", QbzSession.trRev)
hasIcon: false
btnEnabled: !root.busy
onClicked: QbzLocal.mediaSync(root.server, true)
}
}
}
// --- Danger zone --------------------------------------------------
SettingRow { kioskHost: root.kioskHost;
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 }
}
}