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
312 lines
13 KiB
QML
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 }
|
|
}
|
|
}
|