From 41c1b50d611dd0b0760ab9a04837c074f33346b5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:54:16 -0600 Subject: [PATCH] feat(qt): offline cache manager, and stamp the album identity on cached tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last missing Settings surface: the OFFLINE CACHE group (Open manager / Open folder / Clear all) and the manager view behind it — stats bar with the GB limit, sort and failed-only toolbar, A-Z artist rail, album+track rows with per-row re-download and remove. The engine was already here; what was missing were four actions (remove_album, redownload_track, open_folder, clear_all), the rollup and the view. Two bugs the owner's smoke exposed, both older than this view: ALBUM IDENTITY WAS BEING LOST ON EVERY ALBUM DOWNLOAD. A Track inside an /album/get payload carries no nested album object, and `track_cache_info` read exactly that, so `cache_album` wrote `album = NULL, album_id = NULL` for every track it queued. Measured on a real index: 34 of 46 rows, three complete albums. Not cosmetic — `album_id` is the key `remove_album`, `redownload_album` and the downloaded-album registry look rows up by, so those albums could not be removed, refreshed or recognised as downloaded. The album document now stamps id and title onto every track that lacks its own. The reference has the identical bug. AND THE ROLLUP MIXED ARTISTS. Every row without an album_id collapsed into one "__singles__" bucket titled from the first track and credited to its artist, so three albums by three artists rendered as ONE album header while two of the artists vanished from the rail. The key is a ladder now: album id, else (artist, title), else a per-ARTIST singles bucket — two artists can no longer meet in one group even with the broken data. Rows already on disk recover their title from library.db, which does know it, so an existing cache reads correctly without re-downloading. Deltas from the reference, both because Qt is not Slint: covers are paths (slint::Image is not Send, which is why it pre-decodes; Qt has no such constraint) and selection is QML-local, this port's convention, which keeps the document a pure function of the DB so a finishing download cannot fight the user's checkboxes. Clear-all confirms first — the reference fires straight from the glyph, and undoing this one means re-downloading the whole cache. cc --- .../qbz-qt/qml/settings/OfflineSettings.qml | 66 +- crates/qbz-qt/qml/settings/SettingsView.qml | 4 + .../qbz-qt/qml/views/OfflineManagerView.qml | 641 ++++++++++++++++++ crates/qbz-qt/src/offline_cache_qt.rs | 169 ++++- crates/qbz-qt/src/offline_manager_bridge.rs | 247 +++++++ crates/qbz-qt/src/offline_manager_qt.rs | 573 ++++++++++++++++ crates/qbz-qt/src/offline_qt.rs | 22 + 7 files changed, 1711 insertions(+), 11 deletions(-) create mode 100644 crates/qbz-qt/qml/views/OfflineManagerView.qml create mode 100644 crates/qbz-qt/src/offline_manager_bridge.rs create mode 100644 crates/qbz-qt/src/offline_manager_qt.rs diff --git a/crates/qbz-qt/qml/settings/OfflineSettings.qml b/crates/qbz-qt/qml/settings/OfflineSettings.qml index 3d023553c..fa05d5ae0 100644 --- a/crates/qbz-qt/qml/settings/OfflineSettings.qml +++ b/crates/qbz-qt/qml/settings/OfflineSettings.qml @@ -8,11 +8,13 @@ // `OfflineModeEngine::set_induced` (which also takes the #279 stream-first // snapshot). // -// Not shipped (no backend seam in this port — see settings_qt/offline.rs): -// - "Check now": the connectivity actor is private to offline_fwd.rs, so a -// re-probe cannot be triggered from here. The status itself is live. -// - The whole OFFLINE CACHE group (Open manager / Open folder / Clear all): -// the port never brings up OfflineCacheState, so all three would be dead. +// "Check now" IS shipped (`offline-recheck`); so is the live tri-state status +// line (QbzSession.offlineMode / captivePortal) and the lyrics-cache row. +// +// Still missing: the whole OFFLINE CACHE group (Open manager / Open folder / +// Clear all). What blocks it is the MANAGER VIEW, which has no Qt counterpart +// yet — not the engine: `offline_cache_qt.rs` already runs the downloads from +// the album page and the track row. import QtQuick import com.blitzfc.qbz @@ -24,6 +26,10 @@ Column { property var doc: ({}) readonly property var off: doc.offline || ({}) + /// The view-level SettingsConfirmHost (SettingsView.qml). Null in previews + /// — the Clear-all row guards, so a preview degrades to the unconfirmed + /// call rather than swallowing the click. + property var confirmHost: null QbzTheme { id: theme } @@ -72,6 +78,56 @@ Column { SettingsDivider { } SettingsSpacer { } + // ========================== OFFLINE CACHE ============================ + // The downloads half: the manager view plus the two whole-cache actions. + // All three ride QbzOffline, the same bridge the manager view uses — + // "Open folder" and "Clear all" are the manager's own stats-bar buttons, + // offered here too exactly as the reference offers them + // (OfflineSettings.slint:135-167). + GroupHeader { text: QbzSession.tr("OFFLINE CACHE", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Manage offline cache", QbzSession.trRev) + description: QbzSession.tr("Browse and manage your downloaded tracks and albums.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Open manager", QbzSession.trRev) + onClicked: QbzOffline.openManager() + } + } + SettingRow { + label: QbzSession.tr("Cache folder", QbzSession.trRev) + description: QbzSession.tr("Open the folder where offline tracks are stored on disk.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Open folder", QbzSession.trRev) + onClicked: QbzOffline.openFolder() + } + } + SettingRow { + label: QbzSession.tr("Clear cache", QbzSession.trRev) + description: QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev) + SettingsButton { + danger: true + text: QbzSession.tr("Clear all", QbzSession.trRev) + // ONE prompt before the purge. The reference fires straight from + // the button; this port confirms every destructive settings row, + // and undoing this one means re-downloading the whole cache. + onClicked: { + if (!root.confirmHost) { + QbzOffline.clearAll() + return + } + root.confirmHost.ask( + QbzSession.tr("Clear cache", QbzSession.trRev), + QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev), + QbzSession.tr("Clear all", QbzSession.trRev), + function () { QbzOffline.clearAll() }) + } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + // ============================= LYRICS ================================ GroupHeader { text: QbzSession.tr("LYRICS", QbzSession.trRev) } SettingRow { diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index 5a63a5a88..02373756e 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -276,6 +276,10 @@ Item { visible: root.section === 3 width: parent.width doc: root.doc + // "Clear all" purges every downloaded track and + // there is no undo — same treatment as the Local + // Library danger zone below. + confirmHost: confirmHost } LocalLibrarySettings { visible: root.section === 4 diff --git a/crates/qbz-qt/qml/views/OfflineManagerView.qml b/crates/qbz-qt/qml/views/OfflineManagerView.qml new file mode 100644 index 000000000..e54f115bc --- /dev/null +++ b/crates/qbz-qt/qml/views/OfflineManagerView.qml @@ -0,0 +1,641 @@ +// Offline Cache Manager — QML port of crates/qbz-ui/ui/offline/ +// OfflineManagerView.slint. A FULL-PAGE content view (route "offlinemanager"), +// reached only from Settings > Offline > "Open manager", exactly like the +// Blacklist manager next door. +// +// Layout, 1:1 with the reference: a stats bar (totals · usage bar · GB limit +// field · open-folder · clear-all) over a toolbar (sort · failed-only) over a +// two-pane body — a 210px A-Z artist rail on the left, the album-header + +// track rows on the right. +// +// Everything comes out of ONE document, QbzOffline.managerJson. The rollup, +// the ordering, the sizes and the status integers are all Rust-side +// (src/offline_manager_qt.rs); QML never filters and never sorts. +// +// Deltas vs the Slint, each deliberate: +// +// - NO per-view Back chrome (the Slint's NavButtons row, :171). Nav history is +// the global HeaderBar in this port — the same treatment +// views/BlacklistManagerView.qml already documents for the identical case. +// ADR-004 is satisfied by the HeaderBar. +// - The bulk actions are the SHARED controls/QbzMultiSelectBar.qml, not the +// reference's inline `BulkIconBtn` row plus a separate select-all toggle. +// The Slint's own comment (:115-118) says that component was "homologated +// with MultiSelectBar's BulkButton ... so the offline-cache track rows share +// the app-wide bulk-bar style" — this port HAS that bar, it already carries +// both "Select all" and "Clear" as actions, and every other multi-select +// surface here uses it. +// - Selection is QML-local (`selected` map below), the port's convention +// (LocalLibraryView.qml:100) rather than the reference's in-place edits on +// the Slint model. The rebuild is then a pure function of the DB, so a +// download finishing mid-selection cannot fight the user's checkboxes. +// - Covers are file paths in the document, loaded by RoundedImage with +// sourceSize; the reference pre-decodes pixels on its worker because +// slint::Image is not Send. Qt has no such constraint. +// +// Live progress does NOT wait for a rebuild: QbzShell.trackCacheStatusChanged +// is the app-wide row-status channel and this view patches its own rows from +// it, the same way the album page and the track rows do. A rebuild only +// happens on a terminal mutation (offline_manager_qt::refresh_if_open). +// +// Nav re-entry: nav_qt::back()/forward() republish `currentView` and run NO +// per-view load, so this view calls QbzOffline.reload() in +// Component.onCompleted. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Rectangle { + id: root + + // Transparent while the ambient background is active — the frosted content + // panel shows through (HomeView.qml:53 and its twelve siblings). + readonly property bool ambientOn: theme.ambientOn + color: root.ambientOn ? "transparent" : theme.surfaceMain + // Round to the AppShell content-frame bezel (Radius.md): QML clips are + // rectangular, so the frame's rounding never reaches the view. + radius: 12 + + QbzTheme { id: theme } + + // ============================== data ================================== + + readonly property var doc: { + try { + return JSON.parse(QbzOffline.managerJson) + } catch (e) { + return ({}) + } + } + readonly property var rows: root.doc.rows || [] + readonly property var artists: root.doc.artists || [] + readonly property int tracksCount: root.doc.tracksCount || 0 + + /// Checked TRACK rows, keyed by track id. Cleared on every publish: the + /// rows may have been re-filtered or removed underneath, and a checkbox + /// pointing at a row that is gone is a bulk action on nothing. + property var selected: ({}) + readonly property int selectedCount: Object.keys(root.selected).length + onDocChanged: root.selected = ({}) + + /// Live per-row STATUS patches (id -> {status}) merged over the document. + /// Kept separate so the next publish drops them wholesale rather than + /// leaving a stale spinner behind. + /// + /// The signal also carries a progress fraction and the document carries + /// `progress` per row, but neither is drawn — the reference does not draw + /// it either: its StatusIcon spins, it does not fill. Both are kept in the + /// payload because the row-status channel is app-wide and its shape is not + /// this view's to trim. + property var livePatch: ({}) + + Component.onCompleted: QbzOffline.reload() + + Connections { + target: QbzShell + function onTrackCacheStatusChanged(trackId, status, progress) { + var p = Object.assign({}, root.livePatch) + p[trackId] = { "status": status, "progress": progress } + root.livePatch = p + } + } + + function rowStatus(row) { + var p = root.livePatch[row.trackId] + return (row.kind === "track" && p !== undefined) ? p.status : row.status + } + function toggleSelect(trackId) { + var s = Object.assign({}, root.selected) + if (s[trackId] === true) + delete s[trackId] + else + s[trackId] = true + root.selected = s + } + + function bulkAction(id) { + if (id === "clear") { + root.selected = ({}) + return + } + if (id === "select-all") { + var s = ({}) + for (var i = 0; i < root.rows.length; i++) + if (root.rows[i].kind === "track") s[root.rows[i].trackId] = true + root.selected = s + return + } + var ids = Object.keys(root.selected) + if (ids.length === 0) + return + if (id === "redownload") QbzOffline.bulkRedownload(JSON.stringify(ids)) + else if (id === "remove") QbzOffline.bulkRemove(JSON.stringify(ids)) + root.selected = ({}) + } + + // ============================== chrome ================================ + + // The cache-status glyph. Status 2 (downloading) is the only animated one + // and it rides the SHARED shell pulse — never a local Timer or + // NumberAnimation (the repaint-pulse law: every continuous animator ticks + // on QbzShell.pulseMs or the whole window presents at display rate). + component StatusGlyph: Item { + id: glyph + property int status: 0 + width: 18 + height: 18 + + property real spin: 0 + Connections { + target: QbzShell + enabled: glyph.status === 2 && glyph.visible + function onPulseMsChanged() { glyph.spin = (glyph.spin + 12) % 360 } + } + + QbzIcon { + anchors.centerIn: parent + width: 16 + height: 16 + rotation: glyph.status === 2 ? glyph.spin : 0 + name: glyph.status === 2 ? "loader-circle" + : glyph.status === 3 ? "circle-check-big" + : glyph.status === 4 ? "triangle-alert" : "cloud-download" + tintName: glyph.status === 2 ? "accent" + : glyph.status === 3 ? "accent" + : glyph.status === 4 ? "warning" : "muted" + } + } + + // A 30px ghost icon button (the reference's IconBtn). + component GhostBtn: Rectangle { + id: gb + property string name: "" + property string tint: "muted" + signal clicked() + width: 30 + height: 30 + radius: theme.radiusSm + color: gbArea.containsMouse ? theme.surfaceElevated : "transparent" + QbzIcon { + anchors.centerIn: parent + width: 16 + height: 16 + name: gb.name + tintName: gbArea.containsMouse ? "textPrimary" : gb.tint + } + MouseArea { + id: gbArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: gb.clicked() + } + } + + // ============================== layout ================================ + + Column { + anchors.fill: parent + anchors.margins: 24 + spacing: 14 + + // ---- Header ------------------------------------------------------ + Text { + text: QbzSession.tr("Offline Cache", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + } + + // ---- Stats bar --------------------------------------------------- + Rectangle { + width: parent.width + height: 76 + radius: theme.radiusMd + color: theme.surfaceElevated + + // Anchored, NOT a Row: the usage bar has to take exactly the + // slack between the two fixed clusters, and expressing that as a + // width subtraction inside a Row needs a named constant for the + // left cluster — which is how a phantom spacer item ends up in the + // layout adding its own width. + Column { + id: statsLeft + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + text: root.doc.tracksText || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + text: (root.doc.sizeText || "") + " " + (root.doc.limitText || "") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + + Row { + id: statsRight + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + QbzLineEdit { + id: limitField + anchors.verticalCenter: parent.verticalCenter + width: 58 + // Seeded from the document, then owned locally while the + // user types — binding it to `limitGb` would put the + // persisted number back under the cursor on every + // republish (the re-seed rule in QbzLineEdit.qml:67). + text: (root.doc.limitGb || 5).toString() + onAccepted: function (v) { QbzOffline.setLimit(parseInt(v) || 0) } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "GB" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "check" + onClicked: QbzOffline.setLimit(parseInt(limitField.text) || 0) + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "folder" + onClicked: QbzOffline.openFolder() + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "trash-2" + tint: "warning" + onClicked: clearConfirm.open() + } + } + + Rectangle { + anchors.left: statsLeft.right + anchors.right: statsRight.left + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + height: 8 + radius: 4 + color: theme.surfaceMain + Rectangle { + width: parent.width * Math.max(0, Math.min(1, root.doc.usage || 0)) + height: parent.height + radius: 4 + color: (root.doc.usage || 0) >= 1.0 ? theme.danger : theme.accent + } + } + } + + // ---- Toolbar ----------------------------------------------------- + Row { + width: parent.width + height: 32 + spacing: 12 + + QbzSelect { + anchors.verticalCenter: parent.verticalCenter + menuWidth: 160 + sm: true + options: [ + QbzSession.tr("Sort: A-Z", QbzSession.trRev), + QbzSession.tr("Sort: Recent", QbzSession.trRev), + QbzSession.tr("Sort: Largest", QbzSession.trRev), + QbzSession.tr("Sort: Smallest", QbzSession.trRev) + ] + currentIndex: root.doc.sortIndex || 0 + onSelected: function (i) { QbzOffline.setSort(i) } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: failedLabel.implicitWidth + 28 + height: 32 + radius: theme.radiusSm + border.width: 1 + border.color: root.doc.showOnlyFailed === true ? theme.accent : theme.borderSubtle + color: root.doc.showOnlyFailed === true + ? theme.dangerBg + : (failedArea.containsMouse ? theme.surfaceElevated : "transparent") + Text { + id: failedLabel + x: 14 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Failed only", QbzSession.trRev) + color: root.doc.showOnlyFailed === true ? theme.danger : theme.textSecondary + font.pixelSize: theme.fontLegal + } + MouseArea { + id: failedArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzOffline.toggleFailed() + } + } + } + + // ---- Body -------------------------------------------------------- + Item { + width: parent.width + height: parent.height - y + + Row { + anchors.fill: parent + anchors.bottomMargin: root.selectedCount > 0 ? 58 : 0 + spacing: 14 + + // Left A-Z artist rail. + Rectangle { + width: 210 + height: parent.height + radius: theme.radiusMd + color: theme.surfaceCard + clip: true + + ListView { + id: railList + anchors.fill: parent + anchors.margins: 6 + spacing: 2 + // +1 for the "All artists" row, which is not in the + // document (it is the absence of a filter). + model: root.artists.length + 1 + boundsBehavior: Flickable.StopAtBounds + // Row 0 is "All artists" — the ABSENCE of a filter, so + // it is not in the document and the model is one longer + // than the artist list. Every child reads the delegate + // through its own id: a `parent.parent` chain here + // silently resolves to the Column once anything is + // nested one level deeper. + delegate: Rectangle { + id: railRow + required property int index + readonly property bool isAll: index === 0 + readonly property var a: isAll ? null : root.artists[index - 1] + readonly property bool active: isAll + ? (root.doc.selectedArtist || "") === "" + : (a !== null && a.selected === true) + width: railList.width + height: 48 + radius: 8 + color: railRow.active ? theme.surfaceElevated + : (railArea.containsMouse ? theme.surfaceHover : "transparent") + Column { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 8 + spacing: 2 + Text { + width: parent.width + text: railRow.isAll + ? QbzSession.tr("All artists", QbzSession.trRev) + : railRow.a.name + color: railRow.active ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: railRow.active + ? theme.weightSemibold : theme.weightRegular + elide: Text.ElideRight + } + Text { + width: parent.width + text: railRow.isAll + ? (root.doc.tracksText || "") + : railRow.a.meta + color: theme.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + MouseArea { + id: railArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzOffline.selectArtist( + railRow.isAll ? "" : railRow.a.name) + } + } + QbzScrollBar { + target: railList + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } + + // Right list — the three mutually-exclusive body branches, in + // the reference's order: loading · empty · the list. + Item { + width: parent.width - 210 - 14 + height: parent.height + + QbzSpinner { + anchors.centerIn: parent + visible: QbzOffline.managerLoading + } + + Text { + anchors.centerIn: parent + visible: !QbzOffline.managerLoading && root.rows.length === 0 + text: QbzSession.tr("No offline tracks yet", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + + ListView { + id: rowList + anchors.fill: parent + visible: !QbzOffline.managerLoading && root.rows.length > 0 + spacing: 2 + clip: true + model: root.rows + boundsBehavior: Flickable.StopAtBounds + // A cache that can hold thousands of tracks is born + // windowed (TRACK-RULES §6) — never a Flickable over + // every row. + cacheBuffer: 400 + + delegate: Rectangle { + id: rowItem + required property var modelData + readonly property bool isAlbum: modelData.kind === "album" + readonly property bool checked: + root.selected[modelData.trackId] === true + width: rowList.width + height: isAlbum ? 60 : 44 + radius: 8 + color: isAlbum ? theme.surfaceCard + : (rowArea.containsMouse ? theme.surfaceHover : "transparent") + + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: rowItem.isAlbum + ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: if (!rowItem.isAlbum) + QbzOffline.playTrack(rowItem.modelData.trackId) + } + + Row { + anchors.fill: parent + anchors.leftMargin: rowItem.isAlbum ? 10 : 16 + anchors.rightMargin: 12 + spacing: 12 + + // Checkbox (track rows) / spacer (albums). + Item { + width: 16 + height: parent.height + QbzCheckbox { + anchors.centerIn: parent + visible: !rowItem.isAlbum + checked: rowItem.checked + onToggled: root.toggleSelect(rowItem.modelData.trackId) + } + } + + // Leading — album cover or track number. + Item { + width: 40 + height: parent.height + Rectangle { + anchors.centerIn: parent + visible: rowItem.isAlbum + width: 40 + height: 40 + radius: 4 + color: theme.surfaceMain + RoundedImage { + anchors.fill: parent + radius: 4 + source: (rowItem.modelData.cover || "") !== "" + ? "file://" + rowItem.modelData.cover : "" + } + } + Text { + anchors.centerIn: parent + visible: !rowItem.isAlbum + text: rowItem.modelData.number || "" + color: theme.textMuted + font.pixelSize: theme.fontBody + } + } + + // Title + subtitle. + Column { + width: parent.width - 16 - 40 - metaText.width + - 30 * 2 - 18 - 12 * 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: rowItem.modelData.title || "" + color: theme.textPrimary + font.pixelSize: rowItem.isAlbum + ? theme.fontBody : theme.fontLegal + font.weight: rowItem.isAlbum + ? theme.weightSemibold : theme.weightRegular + elide: Text.ElideRight + } + Text { + width: parent.width + text: rowItem.modelData.subtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + + Text { + id: metaText + anchors.verticalCenter: parent.verticalCenter + text: rowItem.modelData.meta || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignRight + } + + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "refresh-cw" + onClicked: rowItem.isAlbum + ? QbzOffline.redownloadAlbum(rowItem.modelData.albumId) + : QbzOffline.redownloadTrack(rowItem.modelData.trackId) + } + GhostBtn { + anchors.verticalCenter: parent.verticalCenter + name: "trash-2" + onClicked: rowItem.isAlbum + ? QbzOffline.removeAlbum(rowItem.modelData.albumId) + : QbzOffline.removeTrack(rowItem.modelData.trackId) + } + StatusGlyph { + anchors.verticalCenter: parent.verticalCenter + status: root.rowStatus(rowItem.modelData) + } + } + } + + QbzScrollBar { + target: rowList + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } + } + + // Bulk bar — the shared component, mounted the way every other + // multi-select surface in this port mounts it. + QbzMultiSelectBar { + visible: root.selectedCount > 0 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + selectedCount: root.selectedCount + actions: [ + { "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false }, + { "id": "redownload", "label": QbzSession.tr("Re-download", QbzSession.trRev), "icon": "refresh-cw", "danger": false, "needsSelection": true }, + { "id": "remove", "label": QbzSession.tr("Remove", QbzSession.trRev), "icon": "trash-2", "danger": true, "needsSelection": true }, + { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true } + ] + onAction: function (id) { root.bulkAction(id) } + } + } + } + + // Clear-all confirmation. The Slint fires it straight from the trash glyph; + // this port asks first, the way every other destructive row in Settings + // does (SettingsConfirmHost). Purging the cache can be an hours-long + // re-download and there is no undo. + QbzConfirmModal { + id: clearConfirm + // Fills the VIEW, like BlacklistManagerView.qml:523 — without it the + // modal has no size and its scrim covers nothing. + anchors.fill: parent + title: QbzSession.tr("Clear cache", QbzSession.trRev) + body: QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev) + confirmLabel: QbzSession.tr("Clear all", QbzSession.trRev) + danger: true + onConfirmed: QbzOffline.clearAll() + } +} diff --git a/crates/qbz-qt/src/offline_cache_qt.rs b/crates/qbz-qt/src/offline_cache_qt.rs index 2cb39b537..e30c94228 100644 --- a/crates/qbz-qt/src/offline_cache_qt.rs +++ b/crates/qbz-qt/src/offline_cache_qt.rs @@ -77,7 +77,33 @@ pub fn row_sink() -> CacheEventSink { } /// Build the DB row metadata from a catalog track (Slint `track_cache_info`). -fn track_cache_info(track: &qbz_models::Track) -> TrackCacheInfo { +/// +/// `album_fallback` is `(id, title)` of the album this batch came FROM, used +/// only when the track carries no nested album of its own. +/// +/// THAT IS NOT AN EDGE CASE — it is the album path's normal state. A `Track` +/// inside an `/album/get` payload has no nested `album` object (the envelope +/// would be repeating itself), so `cache_album` produced rows with +/// `album = NULL, album_id = NULL` for every track it ever queued. Measured on +/// a real index: 34 of 46 rows, three complete albums, all NULL — everything +/// downloaded through the album button since the CMAF path landed. +/// +/// The damage is not cosmetic. `album_id` is the key `remove_album`, +/// `redownload_album` and the downloaded-album registry all look rows up by, +/// so an album cached this way could not be removed, refreshed or recognised +/// as downloaded, and the Offline Manager could not group it. The reference +/// has the identical bug (`offline_cache.rs:149-150` + `:311`). +fn track_cache_info( + track: &qbz_models::Track, + album_fallback: Option<&(String, String)>, +) -> TrackCacheInfo { + let (album, album_id) = match track.album.as_ref() { + Some(a) => (Some(a.title.clone()), Some(a.id.clone())), + None => match album_fallback { + Some((id, title)) => (Some(title.clone()), Some(id.clone())), + None => (None, None), + }, + }; TrackCacheInfo { track_id: track.id, title: track.title.clone(), @@ -86,8 +112,8 @@ fn track_cache_info(track: &qbz_models::Track) -> TrackCacheInfo { .as_ref() .map(|p| p.name.clone()) .unwrap_or_default(), - album: track.album.as_ref().map(|a| a.title.clone()), - album_id: track.album.as_ref().map(|a| a.id.clone()), + album, + album_id, duration_secs: track.duration as u64, quality: "UltraHiRes".to_string(), bit_depth: track.maximum_bit_depth, @@ -126,7 +152,8 @@ pub fn cache_track(id: u64) { return; } }; - let info = track_cache_info(&track); + // A `/track/get` payload DOES carry its album, so no fallback here. + let info = track_cache_info(&track, None); let file_path = off.track_file_path(id, "flac"); let file_path_str = file_path.to_string_lossy().to_string(); @@ -159,6 +186,16 @@ pub fn cache_track(id: u64) { /// Cache a batch of already-fetched catalog tracks (album flow, multi-select /// bulk "Make available offline"; Slint `cache_tracks`). pub fn cache_tracks(tracks: Vec) { + cache_tracks_with_album(tracks, None) +} + +/// [`cache_tracks`] for a batch that came out of ONE album document, whose +/// `(album_id, album_title)` stamps every row that has no nested album of its +/// own — see `track_cache_info`. +pub fn cache_tracks_with_album( + tracks: Vec, + album_fallback: Option<(String, String)>, +) { if tracks.is_empty() { return; } @@ -186,7 +223,7 @@ pub fn cache_tracks(tracks: Vec) { let count = tracks.len(); for track in &tracks { let id = track.id; - let info = track_cache_info(track); + let info = track_cache_info(track, album_fallback.as_ref()); let file_path = off.track_file_path(id, "flac"); let file_path_str = file_path.to_string_lossy().to_string(); { @@ -231,7 +268,10 @@ pub fn cache_album(album_id: String) { crate::toast_qt::error(qbz_i18n::t("This album has no playable tracks")); return; } - cache_tracks(tracks); + // Stamp the album identity from the document we just fetched. Without + // it every row lands with a NULL album_id and the album becomes + // unremovable, unrefreshable and ungroupable — see `track_cache_info`. + cache_tracks_with_album(tracks, Some((album.id.clone(), album.title.clone()))); }); } @@ -266,6 +306,122 @@ pub fn redownload_album(album_id: String, failed_only: bool) { push_status(id, 1, 0.0); spawn_download(&off, id); } + crate::offline_manager_qt::refresh_if_open().await; + }); +} + +/// Re-download ONE track (Slint `redownload_track`): reset its row and spawn +/// the download, skipping a copy that is already in flight. +/// +/// Distinct from `refresh_cached` below, which DELETES the copy first: this +/// keeps the row (and its place in the index) and re-fetches into it, which is +/// what the manager's per-row refresh and its bulk arm want — a failed row has +/// nothing on disk to delete, and deleting a ready one would drop the user's +/// only copy if the network is gone by the time the fetch runs. +pub fn redownload_track(id: u64) { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + { + let guard = off.db.lock().await; + let Some(db) = guard.as_ref() else { + return; + }; + if let Ok(Some(t)) = db.get_track(id) { + if matches!(t.status, qbz_offline_cache::OfflineCacheStatus::Downloading) { + return; + } + } + let _ = db.reset_track_for_redownload(id); + } + push_status(id, 1, 0.0); + spawn_download(&off, id); + crate::offline_manager_qt::refresh_if_open().await; + }); +} + +/// Remove EVERY offline copy of an album (Slint `remove_album`): the shared +/// maintenance sweep does the DB rows + the on-disk bundles, then the library +/// rows and the session set follow it. +pub fn remove_album(album_id: String) { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + let report = { + let guard = off.db.lock().await; + let Some(db) = guard.as_ref() else { + return; + }; + let root = std::path::PathBuf::from(off.get_cache_path()); + qbz_offline_cache::maintenance::remove_album_cached_tracks(db, &root, &album_id) + }; + let report = match report { + Ok(r) => r, + Err(e) => { + log::error!("[qbz-qt] remove album {album_id} failed: {e}"); + return; + } + }; + { + let guard = off.library_db.lock().await; + if let Some(db) = guard.as_ref() { + for id in &report.removed_track_ids { + let _ = db.remove_qobuz_cached_track(*id); + } + } + } + for id in &report.removed_track_ids { + offline_qt::mark_cached(*id, false); + push_status(*id, 0, 0.0); + } + crate::toast_qt::success(qbz_i18n::t("Removed album from offline")); + crate::offline_manager_qt::refresh_if_open().await; + }); +} + +/// Open the cache directory in the desktop file manager (Slint `open_folder`). +/// +/// `xdg-open` / `open` by hand rather than the `rfd` used elsewhere in the +/// port: rfd opens a PICKER, and this row is "show me the folder". Same two +/// binaries the reference shells out to. +pub fn open_folder() { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + let path = off.get_cache_path(); + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() { + log::warn!("[qbz-qt] open offline folder failed: {e}"); + } + }); +} + +/// Clear the WHOLE offline cache — DB rows, on-disk bundles and the library +/// rows (Slint `clear_all`). +/// +/// The Settings copy is precise about the blast radius and so is this: it +/// removes the cached AUDIO. Purchased downloads live in the user's own music +/// folder and are not touched by `purge_all_cached_files`. +pub fn clear_all() { + crate::spawn(async move { + let Some(off) = offline_qt::get().await else { + return; + }; + if let Err(e) = qbz_offline_cache::purge_all_cached_files(&off, &off.library_db).await { + log::error!("[qbz-qt] clear offline cache failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Couldn't clear the cache")); + return; + } + offline_qt::clear_cached_ids(); + crate::toast_qt::success(qbz_i18n::t("Cache cleared")); + crate::offline_manager_qt::refresh_if_open().await; }); } @@ -273,6 +429,7 @@ pub fn redownload_album(album_id: String, failed_only: bool) { pub fn remove_cached(id: u64) { crate::spawn(async move { remove_cached_inner(id, true).await; + crate::offline_manager_qt::refresh_if_open().await; }); } diff --git a/crates/qbz-qt/src/offline_manager_bridge.rs b/crates/qbz-qt/src/offline_manager_bridge.rs new file mode 100644 index 000000000..aff75bfbf --- /dev/null +++ b/crates/qbz-qt/src/offline_manager_bridge.rs @@ -0,0 +1,247 @@ +//! QbzOffline — Offline Cache Manager domain bridge (phase-23 per-domain +//! pattern; the pattern is documented in main.rs). +//! +//! The module is `qbz_offline_manager_bridge`, not `qbz_offline`: bridge +//! modules are never named after a workspace crate (the `qbz_library` +//! collision is the documented precedent). The QML type name comes from the +//! QObject (`QbzOffline`), not from the module. +//! +//! Props: the ONE manager document + its loading flag. +//! Invokables: the view's own toolbar and per-row actions. Every body is a +//! one-line forward into `crate::offline_manager_qt` (the data) or +//! `crate::offline_cache_qt` (the mutations) — the same split the reference +//! makes between `offline_manager.rs` and `offline_cache.rs`. +//! +//! NOT here: the checkbox state. Selection is QML-local in this port +//! (`offline_manager_qt`'s header explains why), so the two bulk arms take the +//! checked ids as a JSON array instead of asking Rust what is ticked. +//! +//! A new bridge is SEVEN artefacts: this file · its `build.rs` `rust_files` +//! entry (which IS the QML singleton registration — `#[qml_element] +//! #[qml_singleton]` only takes effect for files listed there) · the `main.rs` +//! `mod` line · `Main.qml`'s `boot()` call · the `ContentRouter.qml` arm · the +//! view in `build.rs`'s `qml_files` · the entry point that opens it. A bridge +//! missing from `build.rs` does NOT fail the build — its singleton simply does +//! not exist and every `QbzOffline.foo()` becomes a runtime ReferenceError +//! that `cargo check` cannot see. `qml_singleton_xref.py` is the gate. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_offline_manager_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + /// ONE JSON document (`offline_manager_qt::ManagerDoc`: the stats bar, + /// the toolbar state, the artist rail and the flat album+track row + /// list); QML `JSON.parse`s it once per publish. + /// + /// `"{}"` and never `""` — the view parses it on its first frame and + /// an empty string throws. + #[qproperty(QString, manager_json)] + /// Defaults to TRUE, like `QbzBlacklist.blacklistLoading` and for the + /// same reason: the Loader mounts the view BEFORE + /// `Component.onCompleted` fires `reload()`, so a `false` default + /// flashes the empty state ("No offline tracks yet") before the + /// spinner — on the one screen whose whole job is to show you that you + /// DO have downloads. + #[qproperty(bool, manager_loading)] + + type QbzOffline = super::QbzOfflineRust; + + /// Registers this object's Qt-thread hop (Main.qml boots EVERY domain + /// singleton; only QbzSession.boot also fires crate::on_boot). + #[qinvokable] + fn boot(self: Pin<&mut QbzOffline>); + + /// Settings > Offline > "Open manager": push the route and load. + #[qinvokable] + fn open_manager(self: Pin<&mut QbzOffline>); + /// Re-read the index and re-publish. The view calls this from + /// `Component.onCompleted` — nav back/forward runs no per-view load. + #[qinvokable] + fn reload(self: Pin<&mut QbzOffline>); + + // --- Toolbar ------------------------------------------------------ + /// Artist rail click. `""` = All artists. + #[qinvokable] + fn select_artist(self: Pin<&mut QbzOffline>, name: QString); + /// 0 A-Z · 1 Recent · 2 Largest · 3 Smallest. + #[qinvokable] + fn set_sort(self: Pin<&mut QbzOffline>, index: i32); + /// "Failed only" chip. + #[qinvokable] + fn toggle_failed(self: Pin<&mut QbzOffline>); + /// The GB field: persist the cache ceiling and refresh. + #[qinvokable] + fn set_limit(self: Pin<&mut QbzOffline>, gb: i32); + + // --- Whole-cache actions (also the two Settings rows) ------------- + /// Open the cache directory in the desktop file manager. + #[qinvokable] + fn open_folder(self: Pin<&mut QbzOffline>); + /// Purge every cached copy. The CALLER confirms — this does not. + #[qinvokable] + fn clear_all(self: Pin<&mut QbzOffline>); + + // --- Per-row ------------------------------------------------------ + #[qinvokable] + fn remove_track(self: Pin<&mut QbzOffline>, track_id: QString); + #[qinvokable] + fn remove_album(self: Pin<&mut QbzOffline>, album_id: QString); + #[qinvokable] + fn redownload_track(self: Pin<&mut QbzOffline>, track_id: QString); + #[qinvokable] + fn redownload_album(self: Pin<&mut QbzOffline>, album_id: QString); + /// Track row body click — play it now. + #[qinvokable] + fn play_track(self: Pin<&mut QbzOffline>, track_id: QString); + + // --- Bulk (the checked rows; ids come down as a JSON array) ------- + #[qinvokable] + fn bulk_redownload(self: Pin<&mut QbzOffline>, ids_json: QString); + #[qinvokable] + fn bulk_remove(self: Pin<&mut QbzOffline>, ids_json: QString); + } + + impl cxx_qt::Threading for QbzOffline {} +} + +use qbz_offline_manager_bridge::QbzOffline; + +/// Rust side of the offline bridge (plain storage, phase-1 pattern). +pub struct QbzOfflineRust { + manager_json: QString, + manager_loading: bool, +} + +impl Default for QbzOfflineRust { + fn default() -> Self { + Self { + manager_json: QString::from("{}"), + manager_loading: true, + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop (bridges/mod.rs pattern) +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue an offline-bridge mutation onto the Qt event loop (no-op before +/// boot registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzOffline>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +/// Parse a JSON array of track-id strings into ids. Anything unparseable is +/// dropped rather than failing the whole bulk action — the array is built by +/// QML from row ids the document itself published, so a bad entry means one +/// stale row, not a broken button. +fn ids_from_json(raw: &str) -> Vec { + serde_json::from_str::>(raw) + .unwrap_or_default() + .iter() + .filter_map(|s| s.parse::().ok()) + .collect() +} + +impl qbz_offline_manager_bridge::QbzOffline { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] offline Qt thread already registered"); + } + } + + pub fn open_manager(self: Pin<&mut Self>) { + crate::offline_manager_qt::open(); + } + pub fn reload(self: Pin<&mut Self>) { + crate::offline_manager_qt::load(); + } + + pub fn select_artist(self: Pin<&mut Self>, name: QString) { + crate::offline_manager_qt::select_artist(name.to_string()); + } + pub fn set_sort(self: Pin<&mut Self>, index: i32) { + crate::offline_manager_qt::set_sort(index); + } + pub fn toggle_failed(self: Pin<&mut Self>) { + crate::offline_manager_qt::toggle_failed(); + } + pub fn set_limit(self: Pin<&mut Self>, gb: i32) { + crate::offline_manager_qt::set_limit(gb); + } + + pub fn open_folder(self: Pin<&mut Self>) { + crate::offline_cache_qt::open_folder(); + } + pub fn clear_all(self: Pin<&mut Self>) { + crate::offline_cache_qt::clear_all(); + } + + pub fn remove_track(self: Pin<&mut Self>, track_id: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::offline_cache_qt::remove_cached(id); + } + } + pub fn remove_album(self: Pin<&mut Self>, album_id: QString) { + crate::offline_cache_qt::remove_album(album_id.to_string()); + } + pub fn redownload_track(self: Pin<&mut Self>, track_id: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::offline_cache_qt::redownload_track(id); + } + } + pub fn redownload_album(self: Pin<&mut Self>, album_id: QString) { + // `failed_only = false`: the row's refresh button re-fetches the whole + // album, the same arm the album page's "Refresh offline copy" takes. + crate::offline_cache_qt::redownload_album(album_id.to_string(), false); + } + pub fn play_track(self: Pin<&mut Self>, track_id: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::play_track(id); + } + } + + pub fn bulk_redownload(self: Pin<&mut Self>, ids_json: QString) { + for id in ids_from_json(&ids_json.to_string()) { + crate::offline_cache_qt::redownload_track(id); + } + } + pub fn bulk_remove(self: Pin<&mut Self>, ids_json: QString) { + for id in ids_from_json(&ids_json.to_string()) { + crate::offline_cache_qt::remove_cached(id); + } + } +} + +#[cfg(test)] +mod tests { + use super::ids_from_json; + + #[test] + fn bulk_ids_parse_and_survive_a_bad_entry() { + assert_eq!(ids_from_json(r#"["1","22","333"]"#), vec![1, 22, 333]); + // One stale row must not cost the other two their action. + assert_eq!(ids_from_json(r#"["1","plex:9","3"]"#), vec![1, 3]); + assert!(ids_from_json("[]").is_empty()); + assert!(ids_from_json("not json").is_empty()); + } +} diff --git a/crates/qbz-qt/src/offline_manager_qt.rs b/crates/qbz-qt/src/offline_manager_qt.rs new file mode 100644 index 000000000..53dc54613 --- /dev/null +++ b/crates/qbz-qt/src/offline_manager_qt.rs @@ -0,0 +1,573 @@ +//! Offline Cache Manager controller — the Qt port of +//! `crates/qbz/src/offline_manager.rs`. +//! +//! Reads `index.db`, rolls the cached tracks up into artist → album → track, +//! applies the toolbar filters and publishes ONE JSON document on +//! `QbzOffline.managerJson`. Per-item actions are NOT here: they reuse +//! `offline_cache_qt::*`, which is the same split the reference makes. +//! +//! ## Two deliberate deltas from the reference, both because Qt is not Slint +//! +//! 1. **Covers are PATHS, not decoded pixels.** The Slint controller decodes +//! each album cover to a 96px `DecodedPixels` on the worker because +//! `slint::Image` is not `Send` and cannot be built off the UI thread. Qt +//! has no such constraint: the document carries the file path and +//! `RoundedImage` loads it with `sourceSize` set, so the decode happens +//! once, in Qt's own image thread, at the size the row draws. +//! 2. **Selection lives in QML.** The reference edits `selected` in place on +//! the Slint model and asks Rust for the checked ids. This port's +//! convention is the opposite and predates this view — the Local Library's +//! multi-select is `property var tracksSelected: ({})` in the view, with +//! the ids handed down as a JSON array on the bulk call +//! (`LocalLibraryView.qml:100,755`). Following it keeps the whole document +//! a pure function of the DB + the filters, so a rebuild can never fight +//! the user's checkboxes. The `selected` field is therefore absent from the +//! published rows. +//! +//! The filters DO live here, for the reference's own reason: a rebuild runs on +//! a worker and cannot read the view's state, so the artist selection, the +//! sort and the failed-only flag are Rust-side truth and travel back out in +//! the document. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus}; +use serde::Serialize; + +use crate::offline_manager_bridge as bridge; +use cxx_qt_lib::QString; + +const GB: u64 = 1024 * 1024 * 1024; + +/// The route id this view records. `nav_qt` needs no registration for it — +/// a route is `record()` here plus an arm in `ContentRouter.qml`. +pub const ROUTE: &str = "offlinemanager"; + +// --------------------------------------------------------------------------- +// Toolbar filter state (Rust-side truth — see the module header) +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct Filters { + /// "" = all artists. + selected_artist: String, + /// 0 alpha · 1 recent · 2 largest · 3 smallest. + sort: i32, + show_only_failed: bool, +} + +static FILTERS: Mutex> = Mutex::new(None); + +fn current_filters() -> Filters { + FILTERS + .lock() + .ok() + .and_then(|f| f.clone()) + .unwrap_or_default() +} + +fn edit_filters(f: impl FnOnce(&mut Filters)) { + if let Ok(mut guard) = FILTERS.lock() { + let mut cur = guard.clone().unwrap_or_default(); + f(&mut cur); + *guard = Some(cur); + } +} + +// --------------------------------------------------------------------------- +// The document +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ArtistRow { + name: String, + /// "3 albums · 41 tracks", pre-formatted (the reference composes it in + /// Rust too — one plural form per locale, not two interpolations in QML). + meta: String, + selected: bool, +} + +#[derive(Serialize)] +struct Row { + /// "album" | "track". + kind: String, + #[serde(rename = "albumId")] + album_id: String, + #[serde(rename = "trackId")] + track_id: String, + title: String, + subtitle: String, + /// Album: "12 tracks · 480 MB". Track: "3:07". + meta: String, + /// 0 none · 2 downloading · 3 ready · 4 failed (the app-wide row-status + /// vocabulary — `offline_cache_qt` pushes the same integers live). + status: i32, + /// 0.0..1.0, only meaningful while `status == 2`. + progress: f32, + /// Absolute path to the album cover on disk, or "" — QML prefixes + /// `file://`. Album rows only. + cover: String, + /// 1-based position within its album. Track rows only. + number: String, +} + +#[derive(Serialize, Default)] +struct ManagerDoc { + #[serde(rename = "tracksCount")] + tracks_count: i32, + /// "41 tracks" — the PLURAL-CORRECT string, formatted here because there + /// is no plural seam in QML: `QbzSession.tr` takes one msgid and applies + /// no count (`session_bridge.rs:99`). Every other plural in the port that + /// tried to do it QML-side lost the singular form. + #[serde(rename = "tracksText")] + tracks_text: String, + /// "3.4 GB". + #[serde(rename = "sizeText")] + size_text: String, + /// "· of 5.0 GB" or "· Unlimited" — the reference keeps the separator + /// inside the string so the two halves concatenate without a conditional. + #[serde(rename = "limitText")] + limit_text: String, + /// 0.0..1.0 for the usage bar; 0.0 when there is no limit. + usage: f32, + /// The number in the GB field. 5 when unlimited (the reference's default + /// suggestion, not a limit that is in force). + #[serde(rename = "limitGb")] + limit_gb: i32, + #[serde(rename = "selectedArtist")] + selected_artist: String, + #[serde(rename = "sortIndex")] + sort_index: i32, + #[serde(rename = "showOnlyFailed")] + show_only_failed: bool, + artists: Vec, + rows: Vec, +} + +// --------------------------------------------------------------------------- +// Formatting (verbatim from the reference) +// --------------------------------------------------------------------------- + +/// `pub(crate)`: the lyrics-cache Settings row formats its size with the same +/// function in the reference (`offline_manager::human_size`). +pub(crate) fn human_size(bytes: u64) -> String { + let b = bytes as f64; + if bytes >= GB { + format!("{:.1} GB", b / GB as f64) + } else if bytes >= 1024 * 1024 { + format!("{:.0} MB", b / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.0} KB", b / 1024.0) + } else { + format!("{bytes} B") + } +} + +fn track_status_int(s: &OfflineCacheStatus) -> i32 { + match s { + OfflineCacheStatus::Ready => 3, + OfflineCacheStatus::Failed => 4, + _ => 2, + } +} + +fn fmt_duration(secs: u64) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +fn album_size(group: &[CachedTrackInfo]) -> u64 { + group.iter().map(|t| t.file_size_bytes).sum() +} + +// --------------------------------------------------------------------------- +// Publish +// --------------------------------------------------------------------------- + +fn publish(doc: &ManagerDoc, loading: bool) { + let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into()); + bridge::ui(move |mut b| { + b.as_mut().set_manager_json(QString::from(json.as_str())); + b.as_mut().set_manager_loading(loading); + }); +} + +/// Read the index, roll it up, publish. `pub` so the cache mutators can +/// refresh the view after their DB write (`refresh_if_open`). +pub async fn rebuild() { + let f = current_filters(); + let (tracks, limit, cache_path, healed): ( + Vec, + Option, + String, + std::collections::HashMap, + ) = match crate::offline_qt::get().await { + Some(off) => { + let limit = *off.limit_bytes.lock().await; + let cp = off.get_cache_path(); + let tracks = { + let guard = off.db.lock().await; + guard + .as_ref() + .and_then(|db| db.get_all_tracks().ok()) + .unwrap_or_default() + }; + // HEAL, only when something needs it. Rows queued through the + // album button before `track_cache_info` learned to stamp the + // album carry a NULL album title, and no re-download will fix the + // ones already on disk. library.db DOES know their album (the + // downloader writes a `qobuz_download` row per track), so the + // title is recovered from there by qobuz track id. + // + // Titles only — library.db keys albums by `"|<artist>"`, + // not by the Qobuz album id, so `album_id` stays NULL for those + // rows and the grouping below falls to its (artist, title) key. + // That is enough to render them correctly; it is NOT enough for + // `remove_album` / `redownload_album`, which look up BY id — those + // stay per-track for pre-existing rows, and the album buttons on + // such a group act on nothing. Re-downloading the album restamps + // it properly. + let needs_heal = tracks + .iter() + .any(|t| t.album.as_deref().unwrap_or("").is_empty()); + let healed = if needs_heal { + let guard = off.library_db.lock().await; + guard + .as_ref() + .and_then(|db| db.get_qobuz_download_tracks().ok()) + .map(|rows| { + rows.into_iter() + .filter_map(|r| { + let id = r.qobuz_track_id?; + let album = r.album.trim(); + (!album.is_empty()).then(|| (id as u64, album.to_string())) + }) + .collect() + }) + .unwrap_or_default() + } else { + std::collections::HashMap::new() + }; + (tracks, limit, cp, healed) + } + // Logged out: an empty manager, not a spinner that never ends. + None => (Vec::new(), None, String::new(), Default::default()), + }; + + let total_size: u64 = tracks.iter().map(|t| t.file_size_bytes).sum(); + let tracks_count = tracks.len() as i32; + + // group key -> (artist, album_title, tracks) in FIRST-SEEN order. The DB + // returns rows most-recently-accessed first, so that order IS the + // "recent" sort — which is why the sort below leaves it alone for index 1. + // + // THE KEY IS NOT `album_id` ALONE. It used to be, with every row missing + // one collapsing into a single `"__singles__"` bucket — so three complete + // albums by three different artists rendered as ONE album header carrying + // the first artist's name, and the artist rail credited all 34 tracks to + // that one artist while the other two vanished from it (owner smoke, + // 2026-08-16; reproduced against the real index). The reference has the + // same key and the same bug (`offline_manager.rs:142`). + // + // The ladder, most authoritative first: + // 1. the Qobuz album id — an album is an album, even a compilation + // whose tracks each name a different artist; + // 2. otherwise (artist, album title) — two artists can ship an album + // with the same title, so the artist is part of the key; + // 3. otherwise the artist alone: a per-ARTIST "Singles" bucket. Tracks + // with no album at all still group, but they can never again be + // mixed with another artist's. + let mut album_order: Vec<String> = Vec::new(); + let mut albums: BTreeMap<String, (String, String, Vec<CachedTrackInfo>)> = BTreeMap::new(); + for t in tracks { + let title = t + .album + .clone() + .filter(|a| !a.trim().is_empty()) + .or_else(|| healed.get(&t.track_id).cloned()) + .unwrap_or_default(); + let key = match t.album_id.as_deref().filter(|id| !id.trim().is_empty()) { + Some(id) => format!("id:{id}"), + // \x1f (unit separator) is the join: it cannot occur in an artist + // name or an album title, so "A\x1fB C" and "A B\x1fC" can never + // collide the way a "-" or a "|" would. + None if !title.is_empty() => format!("t:{}\x1f{title}", t.artist), + None => format!("s:{}", t.artist), + }; + if !albums.contains_key(&key) { + album_order.push(key.clone()); + } + // Raw literal, NOT a msgid — 1:1 with the reference + // (`offline_manager.rs:146`), and "Singles" is absent from all eight + // catalogs. Translating it here would add a ninth string that only + // this port has, which is exactly the drift the shared catalog exists + // to prevent. + let display_title = if title.is_empty() { + "Singles".to_string() + } else { + title + }; + albums + .entry(key) + .or_insert_with(|| (t.artist.clone(), display_title, Vec::new())) + .2 + .push(t); + } + + // Artist rail: A-Z (BTreeMap order), with per-artist album + track counts. + let mut artist_stats: BTreeMap<String, (usize, usize)> = BTreeMap::new(); + for aid in &album_order { + let (artist, _title, group) = &albums[aid]; + let e = artist_stats.entry(artist.clone()).or_insert((0, 0)); + e.0 += 1; + e.1 += group.len(); + } + let artists: Vec<ArtistRow> = artist_stats + .iter() + .map(|(name, (albums_n, tracks_n))| ArtistRow { + name: name.clone(), + meta: qbz_i18n::t_args( + "{} albums · {} tracks", + &[&albums_n.to_string(), &tracks_n.to_string()], + ), + selected: *name == f.selected_artist, + }) + .collect(); + + let mut order = album_order.clone(); + match f.sort { + 0 => order.sort_by(|a, b| albums[a].1.to_lowercase().cmp(&albums[b].1.to_lowercase())), + 2 => order.sort_by(|a, b| album_size(&albums[b].2).cmp(&album_size(&albums[a].2))), + 3 => order.sort_by(|a, b| album_size(&albums[a].2).cmp(&album_size(&albums[b].2))), + // 1 = recent — the DB's last_accessed_at DESC order, already there. + _ => {} + } + + let mut rows: Vec<Row> = Vec::new(); + for aid in &order { + let (artist, title, group) = &albums[aid]; + if !f.selected_artist.is_empty() && *artist != f.selected_artist { + continue; + } + let any_failed = group + .iter() + .any(|t| matches!(t.status, OfflineCacheStatus::Failed)); + if f.show_only_failed && !any_failed { + continue; + } + let any_active = group.iter().any(|t| { + matches!( + t.status, + OfflineCacheStatus::Queued | OfflineCacheStatus::Downloading + ) + }); + let all_ready = group + .iter() + .all(|t| matches!(t.status, OfflineCacheStatus::Ready)); + let album_status = if any_failed { + 4 + } else if any_active { + 2 + } else if all_ready { + 3 + } else { + 0 + }; + // The FIRST track whose cover resolves — within one album only some + // tracks carry one (per-track CMAF folders, mixed v1/v2 rows). + let cover = group + .iter() + .find_map(|t| t.resolve_cover_path(&cache_path)) + .unwrap_or_default(); + // The row's albumId is the REAL Qobuz id or "" — never the synthetic + // group key. The album buttons pass it to `remove_album` / + // `redownload_album`, which query `album_id` in SQL; handing them + // `t:Artist\u{1}Title` would match nothing while looking like it had. + let real_album_id = group + .first() + .and_then(|t| t.album_id.clone()) + .filter(|id| !id.trim().is_empty()) + .unwrap_or_default(); + rows.push(Row { + kind: "album".into(), + album_id: real_album_id.clone(), + track_id: String::new(), + title: title.clone(), + subtitle: artist.clone(), + meta: qbz_i18n::t_args( + "{} tracks · {}", + &[&group.len().to_string(), &human_size(album_size(group))], + ), + status: album_status, + progress: 0.0, + cover, + number: String::new(), + }); + for (i, t) in group.iter().enumerate() { + if f.show_only_failed && !matches!(t.status, OfflineCacheStatus::Failed) { + continue; + } + rows.push(Row { + kind: "track".into(), + album_id: real_album_id.clone(), + track_id: t.track_id.to_string(), + title: t.title.clone(), + subtitle: t.artist.clone(), + meta: fmt_duration(t.duration_secs), + status: track_status_int(&t.status), + progress: t.progress_percent as f32 / 100.0, + cover: String::new(), + number: (i + 1).to_string(), + }); + } + } + + let (limit_text, usage, limit_gb) = match limit { + Some(l) if l > 0 => ( + qbz_i18n::t_args("· of {}", &[&human_size(l)]), + (total_size as f32 / l as f32).clamp(0.0, 1.0), + (l / GB).max(1) as i32, + ), + _ => (qbz_i18n::t("· Unlimited"), 0.0, 5), + }; + + publish( + &ManagerDoc { + tracks_count, + tracks_text: qbz_i18n::tf( + "{} track", + "{} tracks", + tracks_count as i64, + &[&tracks_count.to_string()], + ), + size_text: human_size(total_size), + limit_text, + usage, + limit_gb, + selected_artist: f.selected_artist, + sort_index: f.sort, + show_only_failed: f.show_only_failed, + artists, + rows, + }, + false, + ); +} + +/// Settings > Offline > "Open manager": push the route, then load. +pub fn open() { + crate::nav_qt::record(ROUTE); + load(); +} + +/// Mark loading and rebuild. The view also calls this from +/// `Component.onCompleted` — nav back/forward runs no per-view load. +pub fn load() { + bridge::ui(|mut b| b.as_mut().set_manager_loading(true)); + crate::spawn(async move { rebuild().await }); +} + +/// Rebuild ONLY when the manager is the route on screen. +/// +/// The reference rebuilds unconditionally after every cache mutation, which is +/// free for it because the same call also refreshes its Slint model. Here the +/// mutators fire from the album page and the track rows far more often than +/// from this view, and a rebuild is a full `get_all_tracks()` — so the gate +/// keeps a download started from an album page off the SQLite file it does not +/// need to touch. Live per-row status still reaches the view either way: it +/// rides `QbzShell.trackCacheStatusChanged`, the same signal every other view +/// patches its rows from. +pub async fn refresh_if_open() { + if crate::nav_qt::current_view() == ROUTE { + rebuild().await; + } +} + +// --------------------------------------------------------------------------- +// Toolbar actions +// --------------------------------------------------------------------------- + +pub fn select_artist(name: String) { + edit_filters(|f| f.selected_artist = name); + crate::spawn(async move { rebuild().await }); +} + +pub fn set_sort(index: i32) { + edit_filters(|f| f.sort = index); + crate::spawn(async move { rebuild().await }); +} + +pub fn toggle_failed() { + edit_filters(|f| f.show_only_failed = !f.show_only_failed); + crate::spawn(async move { rebuild().await }); +} + +/// Set the cache size limit in GB, persist it, refresh. +/// +/// Clamped at 1 GB like the reference: the field is a free-text number input, +/// and a 0 would read as "unlimited" everywhere downstream while the user +/// believed they had just forbidden caching entirely. +pub fn set_limit(gb: i32) { + crate::spawn(async move { + let bytes = (gb.max(1) as u64) * GB; + if let Some(off) = crate::offline_qt::get().await { + *off.limit_bytes.lock().await = Some(bytes); + } + crate::offline_qt::persist_limit(bytes).await; + rebuild().await; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_size_matches_the_reference_thresholds() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(512), "512 B"); + assert_eq!(human_size(2048), "2 KB"); + assert_eq!(human_size(5 * 1024 * 1024), "5 MB"); + // The GB arm is the only one with a decimal — the stats bar reads + // "3.4 GB", never "3 GB". + assert_eq!(human_size(3 * GB + GB / 2), "3.5 GB"); + } + + /// The status integers are the app-wide row vocabulary that + /// `offline_cache_qt::push_status` also emits. A mismatch here would show + /// a spinner on a ready row (or nothing on a failed one) with no error + /// anywhere — the album/track glyph is the only symptom. + #[test] + fn status_ints_match_the_row_vocabulary() { + assert_eq!(track_status_int(&OfflineCacheStatus::Ready), 3); + assert_eq!(track_status_int(&OfflineCacheStatus::Failed), 4); + assert_eq!(track_status_int(&OfflineCacheStatus::Queued), 2); + assert_eq!(track_status_int(&OfflineCacheStatus::Downloading), 2); + } + + #[test] + fn duration_is_mss_with_a_padded_seconds_field() { + assert_eq!(fmt_duration(0), "0:00"); + assert_eq!(fmt_duration(7), "0:07"); + assert_eq!(fmt_duration(187), "3:07"); + // No hours field, exactly like the reference. + assert_eq!(fmt_duration(3671), "61:11"); + } + + /// The filters are process-global; the actions must round-trip through + /// them or the rebuild reads defaults and the toolbar silently resets. + #[test] + fn filter_edits_round_trip() { + edit_filters(|f| { + f.selected_artist = "Boards of Canada".into(); + f.sort = 2; + f.show_only_failed = true; + }); + let f = current_filters(); + assert_eq!(f.selected_artist, "Boards of Canada"); + assert_eq!(f.sort, 2); + assert!(f.show_only_failed); + // Leave the global as the rest of the suite expects to find it. + edit_filters(|f| *f = Filters::default()); + } +} diff --git a/crates/qbz-qt/src/offline_qt.rs b/crates/qbz-qt/src/offline_qt.rs index de2c93e94..7264ca084 100644 --- a/crates/qbz-qt/src/offline_qt.rs +++ b/crates/qbz-qt/src/offline_qt.rs @@ -83,6 +83,19 @@ pub async fn activate(user_id: u64) { load_cached_ids().await; } +/// Persist the cache size limit (bytes) next to `index.db`, so the manager's +/// edit-limit survives a restart. 1:1 with `crates/qbz/src/offline.rs:89-96` — +/// SAME file name in the SAME per-user directory, which is what lets the two +/// frontends share one limit instead of quietly disagreeing about it. +pub async fn persist_limit(bytes: u64) { + if let Some(off) = get().await { + let path = limit_file(&off.get_cache_path()); + if let Err(e) = std::fs::write(&path, bytes.to_string()) { + log::warn!("[qbz-qt] offline: persist limit failed: {e}"); + } + } +} + /// Tear down the offline cache on logout. pub async fn deactivate() { if let Some(state) = slot().lock().await.take() { @@ -118,6 +131,15 @@ pub fn is_cached(track_id: &str) -> bool { cached_ids().lock().map(|s| s.contains(&id)).unwrap_or(false) } +/// Drop the whole session set — after a cache purge, where marking each id +/// individually would mean walking a list the purge already threw away +/// (Slint `clear_all`: `cached_ids().lock().clear()`). +pub fn clear_cached_ids() { + if let Ok(mut s) = cached_ids().lock() { + s.clear(); + } +} + pub fn mark_cached(track_id: u64, cached: bool) { if let Ok(mut set) = cached_ids().lock() { if cached {