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