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
This commit is contained in:
vicrodh committed 2026-09-09 17:12:45 -06:00
1 parent a097b7bf76
commit 75b8eee1c6
25 files changed
+1863 -90

No files matched your search

@@ -78,7 +78,41 @@ Column {
readonly property string effectiveUser:
root.userInput !== "" ? root.userInput : (root.state.username || "")
readonly property bool canConnect:
root.effectiveUrl !== "" && root.effectiveUser !== "" && root.passInput !== ""
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 }
@@ -186,6 +220,7 @@ Column {
// =============================== body =================================
Column {
visible: root.state.enabled === true && root.state.collapsed !== true
onVisibleChanged: { if (!visible) root.cancelPairing() }
width: parent.width
spacing: 4
@@ -197,6 +232,7 @@ Column {
Row {
spacing: 8
QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
width: 240
text: root.state.serverUrl || ""
placeholder: root.urlPlaceholder
@@ -207,7 +243,7 @@ Column {
anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Test", QbzSession.trRev)
hasIcon: false
btnEnabled: root.effectiveUrl !== ""
btnEnabled: root.effectiveUrl.trim() !== "" && !root.busy
onClicked: QbzLocal.mediaTest(root.server, root.effectiveUrl)
}
}
@@ -216,6 +252,7 @@ Column {
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)
@@ -228,6 +265,7 @@ Column {
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.
@@ -244,18 +282,49 @@ Column {
// --- Connect ------------------------------------------------------
SettingRow { kioskHost: root.kioskHost;
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)
description: root.statusText
QbzPrimaryButton {
label: QbzSession.tr("Connect", QbzSession.trRev)
btnEnabled: root.canConnect && !QbzLocal.mediaSyncing
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
@@ -271,28 +340,30 @@ Column {
// The progress text replaces the buttons while a sweep runs —
// one control, one state, and no button to click twice.
Text {
visible: QbzLocal.mediaSyncing
visible: root.operation.syncing === true
anchors.verticalCenter: parent.verticalCenter
text: QbzLocal.mediaSyncProgress !== ""
? QbzSession.tr("Syncing…", QbzSession.trRev) + " " + QbzLocal.mediaSyncProgress
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: !QbzLocal.mediaSyncing
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: !QbzLocal.mediaSyncing && root.state.lastSyncAt > 0
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)
}
}